Compare commits
64 Commits
2.0.1
...
ac4c22c2fb
| Author | SHA1 | Date | |
|---|---|---|---|
| ac4c22c2fb | |||
| 52968df567 | |||
| 2220a0d4f2 | |||
| 6110f712bd | |||
| 3a5c285511 | |||
| 4021881f11 | |||
| 623b562e8d | |||
| 08619255c7 | |||
| 472860f426 | |||
| 6d0c52b96f | |||
| 5e9842f02d | |||
| 480b30d909 | |||
| 839ec53c17 | |||
| 6e5e587998 | |||
| d87d6e038e | |||
| 0d2eb229cf | |||
| 82b5c886cb | |||
| 751261ffc4 | |||
| 0e7305baa4 | |||
| 484d08d3d4 | |||
| 663125aa0e | |||
| 937a061cdd | |||
| 601ffc4a75 | |||
| bde29e7e98 | |||
| afe985661a | |||
| ce8ece23a5 | |||
| 9aa55864f8 | |||
| a98bcd5b66 | |||
| ca829dde4c | |||
| 51048678bb | |||
| 7e6d449c52 | |||
| 0cdbecc031 | |||
| f4f3ef543f | |||
| c775fa0def | |||
| 13b27a2a1a | |||
| 63094f7e49 | |||
| d34363c650 | |||
| 1bde92b974 | |||
| 6c183aea03 | |||
| 3775f2dc7c | |||
| 8807d6e621 | |||
| 3552c9c476 | |||
| 035d74d412 | |||
| 45f1f521e2 | |||
| db3b10d324 | |||
| 98f4bd032a | |||
| dd79fd9ecd | |||
| 729279ea78 | |||
| 9303c5acfe | |||
| 2b533cf1db | |||
| 74c0227ec7 | |||
| fb9a6fcb9b | |||
| 0a86dbed49 | |||
| 6a574597c5 | |||
| de5eefb9f4 | |||
| 29ce0863ca | |||
| 647ee74bf6 | |||
| 5ca056dbc8 | |||
| e7bb991df7 | |||
| 92ab7d572c | |||
| 795a6b7290 | |||
| d010d68056 | |||
| 8ab927333b | |||
| acd31a9154 |
37
README.md
37
README.md
@@ -32,3 +32,40 @@ On some systems, you may need to add `--security-opt seccomp=unconfined` to allo
|
||||
|
||||
Once running, you should be able to access the UI via http://localhost:8080.
|
||||
|
||||
### Video
|
||||
|
||||
QueueCube supports video as well. Just set the environment variable `ENABLE_VIDEO=1`.
|
||||
|
||||
#### Video + Docker
|
||||
|
||||
When running in a Docker container, there are a few extra steps needed to make this work.
|
||||
|
||||
First, make sure you're passing in `/dev/dri` as a volume in the container. Since mpd is capable of rendering directly to the GPU, this would be the most performant.
|
||||
|
||||
```
|
||||
volumes:
|
||||
- /dev/dri:/dev/dri
|
||||
```
|
||||
|
||||
as well as the X11 socket:
|
||||
|
||||
```
|
||||
- /tmp/.X11-unix:/tmp/.X11-unix
|
||||
```
|
||||
|
||||
In order to be able to interact with the X session from the Docker container, you may also need to explicitly allow connections to the host via:
|
||||
|
||||
```
|
||||
$ xhost +local:
|
||||
```
|
||||
|
||||
Since the container will also need to be able to access the GPU device, it may be required to run the container in "privileged" mode:
|
||||
|
||||
```
|
||||
privileged: true
|
||||
```
|
||||
|
||||
(or `--privileged`).
|
||||
|
||||
On Podman rootless, it seems to be enough to just run it with the "unconfined" security profile (`seccomp=unconfined`).
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
[]
|
||||
2121
backend/package-lock.json
generated
2121
backend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,112 +0,0 @@
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { PlaylistItem } from './types';
|
||||
import { getLinkPreview } from 'link-preview-js';
|
||||
|
||||
export class FavoritesStore {
|
||||
onFavoritesChanged: (favorites: PlaylistItem[]) => void = () => {};
|
||||
|
||||
private storePath: string;
|
||||
private favorites: PlaylistItem[] = [];
|
||||
|
||||
constructor() {
|
||||
this.storePath = this.determineStorePath();
|
||||
this.loadFavorites();
|
||||
}
|
||||
|
||||
private determineStorePath(): string {
|
||||
const storeFilename = 'favorites.json';
|
||||
var storePath = path.join(os.tmpdir(), 'queuecube');
|
||||
|
||||
// Check for explicitly set path
|
||||
if (process.env.STORE_PATH) {
|
||||
storePath = path.resolve(process.env.STORE_PATH);
|
||||
}
|
||||
|
||||
// In production (in a container), use /app/data
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
storePath = path.resolve('/app/data');
|
||||
}
|
||||
|
||||
fs.mkdir(storePath, { recursive: true }).catch(err => {
|
||||
console.error('Failed to create intermediate directory:', err);
|
||||
});
|
||||
|
||||
return path.join(storePath, storeFilename);
|
||||
}
|
||||
|
||||
private async loadFavorites() {
|
||||
try {
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||
|
||||
const data = await fs.readFile(this.storePath, 'utf-8');
|
||||
this.favorites = JSON.parse(data);
|
||||
} catch (error) {
|
||||
// If file doesn't exist or is invalid, start with empty array
|
||||
this.favorites = [];
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
private async saveFavorites() {
|
||||
await fs.writeFile(this.storePath, JSON.stringify(this.favorites, null, 2));
|
||||
this.onFavoritesChanged(this.favorites);
|
||||
}
|
||||
|
||||
async getFavorites(): Promise<PlaylistItem[]> {
|
||||
return this.favorites;
|
||||
}
|
||||
|
||||
async addFavorite(item: PlaylistItem, fetchMetadata: boolean = true): Promise<void> {
|
||||
// Check if the item already exists by filename
|
||||
const exists = this.favorites.some(f => f.filename === item.filename);
|
||||
if (!exists) {
|
||||
this.favorites.push({
|
||||
...item,
|
||||
id: this.favorites.length // Generate new ID
|
||||
});
|
||||
await this.saveFavorites();
|
||||
} else {
|
||||
// Otherwise, update the item with the new metadata
|
||||
const index = this.favorites.findIndex(f => f.filename === item.filename);
|
||||
this.favorites[index] = {
|
||||
...this.favorites[index],
|
||||
...item,
|
||||
};
|
||||
await this.saveFavorites();
|
||||
}
|
||||
|
||||
// If the item is missing metadata, fetch it
|
||||
if (fetchMetadata && !item.metadata) {
|
||||
await this.fetchMetadata(item);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMetadata(item: PlaylistItem): Promise<void> {
|
||||
console.log("Fetching metadata for " + item.filename);
|
||||
const metadata = await getLinkPreview(item.filename);
|
||||
|
||||
item.metadata = {
|
||||
title: (metadata as any)?.title,
|
||||
description: (metadata as any)?.description,
|
||||
siteName: (metadata as any)?.siteName,
|
||||
};
|
||||
|
||||
console.log("Metadata fetched for " + item.filename);
|
||||
console.log(item);
|
||||
|
||||
await this.addFavorite(item, false);
|
||||
}
|
||||
|
||||
async removeFavorite(id: number): Promise<void> {
|
||||
this.favorites = this.favorites.filter(f => f.id !== id);
|
||||
await this.saveFavorites();
|
||||
}
|
||||
|
||||
async clearFavorites(): Promise<void> {
|
||||
this.favorites = [];
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import { Socket } from "net";
|
||||
import { WebSocket } from "ws";
|
||||
import { getLinkPreview } from "link-preview-js";
|
||||
import { PlaylistItem, LinkMetadata } from './types';
|
||||
import { FavoritesStore } from "./FavoritesStore";
|
||||
|
||||
interface PendingCommand {
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason: any) => void;
|
||||
}
|
||||
|
||||
enum UserEvent {
|
||||
PlaylistUpdate = "playlist_update",
|
||||
NowPlayingUpdate = "now_playing_update",
|
||||
VolumeUpdate = "volume_update",
|
||||
FavoritesUpdate = "favorites_update",
|
||||
MetadataUpdate = "metadata_update",
|
||||
MPDUpdate = "mpd_update",
|
||||
}
|
||||
|
||||
export class MediaPlayer {
|
||||
private playerProcess: ChildProcess;
|
||||
private socket: Socket;
|
||||
private eventSubscribers: WebSocket[] = [];
|
||||
private favoritesStore: FavoritesStore;
|
||||
|
||||
private pendingCommands: Map<number, PendingCommand> = new Map();
|
||||
private requestId: number = 1;
|
||||
private dataBuffer: string = '';
|
||||
private metadata: Map<string, LinkMetadata> = new Map();
|
||||
|
||||
constructor() {
|
||||
const socketFilename = Math.random().toString(36).substring(2, 10);
|
||||
const socketPath = `/tmp/mpv-${socketFilename}`;
|
||||
|
||||
console.log("Starting player process");
|
||||
this.playerProcess = spawn("mpv", [
|
||||
"--no-video",
|
||||
"--no-terminal",
|
||||
"--idle=yes",
|
||||
"--input-ipc-server=" + socketPath
|
||||
]);
|
||||
|
||||
this.socket = new Socket();
|
||||
|
||||
this.playerProcess.on("spawn", () => {
|
||||
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
||||
setTimeout(() => {
|
||||
this.connectToSocket(socketPath);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
this.favoritesStore = new FavoritesStore();
|
||||
this.favoritesStore.onFavoritesChanged = (favorites) => {
|
||||
this.handleEvent(UserEvent.FavoritesUpdate, { favorites });
|
||||
};
|
||||
}
|
||||
|
||||
public async getPlaylist(): Promise<PlaylistItem[]> {
|
||||
return this.writeCommand("get_property", ["playlist"])
|
||||
.then((response) => {
|
||||
// Enhance playlist items with metadata
|
||||
const playlist = response.data as PlaylistItem[];
|
||||
return playlist.map((item: PlaylistItem) => ({
|
||||
...item,
|
||||
metadata: this.metadata.get(item.filename) || {}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
public async getNowPlaying(): Promise<PlaylistItem> {
|
||||
const playlist = await this.getPlaylist();
|
||||
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||
const fetchMediaTitle = async (): Promise<string> => {
|
||||
return (await this.writeCommand("get_property", ["media-title"])).data;
|
||||
};
|
||||
|
||||
if (currentlyPlayingSong !== undefined) {
|
||||
// Use media title if we don't have a title
|
||||
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
|
||||
return {
|
||||
...currentlyPlayingSong,
|
||||
title: await fetchMediaTitle()
|
||||
};
|
||||
}
|
||||
|
||||
return currentlyPlayingSong;
|
||||
}
|
||||
|
||||
const mediaTitle = await fetchMediaTitle();
|
||||
return {
|
||||
id: 0,
|
||||
filename: mediaTitle,
|
||||
title: mediaTitle
|
||||
};
|
||||
}
|
||||
|
||||
public async getCurrentFile(): Promise<string> {
|
||||
return this.writeCommand("get_property", ["stream-open-filename"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
public async getPauseState(): Promise<boolean> {
|
||||
return this.writeCommand("get_property", ["pause"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
public async getVolume(): Promise<number> {
|
||||
return this.writeCommand("get_property", ["volume"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
public async getIdle(): Promise<boolean> {
|
||||
return this.writeCommand("get_property", ["idle"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
public async append(url: string) {
|
||||
await this.loadFile(url, "append-play");
|
||||
}
|
||||
|
||||
public async replace(url: string) {
|
||||
await this.loadFile(url, "replace");
|
||||
}
|
||||
|
||||
public async play() {
|
||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
|
||||
}
|
||||
|
||||
public async pause() {
|
||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
|
||||
}
|
||||
|
||||
public async skip() {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
|
||||
}
|
||||
|
||||
public async skipTo(index: number) {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
|
||||
}
|
||||
|
||||
public async previous() {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
|
||||
}
|
||||
|
||||
public async deletePlaylistItem(index: number) {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index]));
|
||||
}
|
||||
|
||||
public async setVolume(volume: number) {
|
||||
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
|
||||
}
|
||||
|
||||
public subscribe(ws: WebSocket) {
|
||||
this.eventSubscribers.push(ws);
|
||||
}
|
||||
|
||||
public unsubscribe(ws: WebSocket) {
|
||||
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
|
||||
}
|
||||
|
||||
public async getFavorites(): Promise<PlaylistItem[]> {
|
||||
return this.favoritesStore.getFavorites();
|
||||
}
|
||||
|
||||
public async addFavorite(item: PlaylistItem) {
|
||||
return this.favoritesStore.addFavorite(item);
|
||||
}
|
||||
|
||||
public async removeFavorite(id: number) {
|
||||
return this.favoritesStore.removeFavorite(id);
|
||||
}
|
||||
|
||||
public async clearFavorites() {
|
||||
return this.favoritesStore.clearFavorites();
|
||||
}
|
||||
|
||||
private async loadFile(url: string, mode: string) {
|
||||
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode]));
|
||||
|
||||
this.fetchMetadataAndNotify(url).catch(error => {
|
||||
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
||||
});
|
||||
}
|
||||
|
||||
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
|
||||
return func()
|
||||
.then((result) => {
|
||||
// Notify all subscribers
|
||||
this.handleEvent(event, {});
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private async writeCommand(command: string, args: any[]): Promise<any> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const id = this.requestId++;
|
||||
|
||||
const commandObject = JSON.stringify({
|
||||
command: [command, ...args],
|
||||
request_id: id
|
||||
});
|
||||
|
||||
this.pendingCommands.set(id, { resolve, reject });
|
||||
this.socket.write(commandObject + '\n');
|
||||
|
||||
// Add timeout to prevent hanging promises
|
||||
setTimeout(() => {
|
||||
if (this.pendingCommands.has(id)) {
|
||||
const pending = this.pendingCommands.get(id);
|
||||
if (pending) {
|
||||
pending.reject(new Error('Command timed out'));
|
||||
this.pendingCommands.delete(id);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchMetadataAndNotify(url: string) {
|
||||
try {
|
||||
console.log("Fetching metadata for " + url);
|
||||
const metadata = await getLinkPreview(url);
|
||||
this.metadata.set(url, {
|
||||
title: (metadata as any)?.title,
|
||||
description: (metadata as any)?.description,
|
||||
siteName: (metadata as any)?.siteName,
|
||||
});
|
||||
|
||||
console.log("Metadata fetched for " + url);
|
||||
console.log(this.metadata.get(url));
|
||||
|
||||
// Notify clients that metadata has been updated
|
||||
this.handleEvent(UserEvent.MetadataUpdate, {
|
||||
url,
|
||||
metadata: this.metadata.get(url)
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private connectToSocket(path: string) {
|
||||
this.socket.connect(path);
|
||||
this.socket.on("data", data => this.receiveData(data.toString()));
|
||||
}
|
||||
|
||||
private handleEvent(event: string, data: any) {
|
||||
console.log("Event [" + event + "]: " + JSON.stringify(data, null, 2));
|
||||
|
||||
// Notify all subscribers
|
||||
this.eventSubscribers.forEach(subscriber => {
|
||||
subscriber.send(JSON.stringify({ event, data }));
|
||||
});
|
||||
}
|
||||
|
||||
private receiveData(data: string) {
|
||||
this.dataBuffer += data;
|
||||
|
||||
const lines = this.dataBuffer.split('\n');
|
||||
|
||||
// Keep last incomplete line in the buffer
|
||||
this.dataBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().length > 0) {
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
if (response.request_id) {
|
||||
const pending = this.pendingCommands.get(response.request_id);
|
||||
if (pending) {
|
||||
pending.resolve(response);
|
||||
this.pendingCommands.delete(response.request_id);
|
||||
}
|
||||
} else if (response.event) {
|
||||
this.handleEvent(UserEvent.MPDUpdate, response);
|
||||
} else {
|
||||
console.log(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing JSON:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,193 +0,0 @@
|
||||
import express from "express";
|
||||
import expressWs from "express-ws";
|
||||
import { MediaPlayer } from "./MediaPlayer";
|
||||
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
||||
import { PlaylistItem } from './types';
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
expressWs(app);
|
||||
|
||||
const apiRouter = express.Router();
|
||||
const mediaPlayer = new MediaPlayer();
|
||||
|
||||
const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
|
||||
return async (req: any, res: any) => {
|
||||
try {
|
||||
await func(req, res);
|
||||
} catch (error: any) {
|
||||
res.status(500).send(JSON.stringify({ success: false, error: error.message }));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
apiRouter.get("/playlist", withErrorHandling(async (req, res) => {
|
||||
const playlist = await mediaPlayer.getPlaylist();
|
||||
res.send(playlist);
|
||||
}));
|
||||
|
||||
apiRouter.post("/playlist", withErrorHandling(async (req, res) => {
|
||||
const { url } = req.body as { url: string };
|
||||
await mediaPlayer.append(url);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/playlist/:index", withErrorHandling(async (req, res) => {
|
||||
const { index } = req.params as { index: string };
|
||||
await mediaPlayer.deletePlaylistItem(parseInt(index));
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/playlist/replace", withErrorHandling(async (req, res) => {
|
||||
const { url } = req.body as { url: string };
|
||||
await mediaPlayer.replace(url);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/play", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.play();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/pause", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.pause();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/skip", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.skip();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/skip/:index", withErrorHandling(async (req, res) => {
|
||||
const { index } = req.params as { index: string };
|
||||
await mediaPlayer.skipTo(parseInt(index));
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/previous", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.previous();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
|
||||
const playingItem = await mediaPlayer.getNowPlaying();
|
||||
const currentFile = await mediaPlayer.getCurrentFile();
|
||||
const pauseState = await mediaPlayer.getPauseState();
|
||||
const volume = await mediaPlayer.getVolume();
|
||||
const idle = await mediaPlayer.getIdle();
|
||||
|
||||
res.send(JSON.stringify({
|
||||
success: true,
|
||||
playingItem: playingItem,
|
||||
isPaused: pauseState,
|
||||
volume: volume,
|
||||
isIdle: idle,
|
||||
currentFile: currentFile
|
||||
}));
|
||||
}));
|
||||
|
||||
apiRouter.post("/volume", withErrorHandling(async (req, res) => {
|
||||
const { volume } = req.body as { volume: number };
|
||||
await mediaPlayer.setVolume(volume);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.ws("/events", (ws, req) => {
|
||||
console.log("Events client connected");
|
||||
mediaPlayer.subscribe(ws);
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log("Events client disconnected");
|
||||
mediaPlayer.unsubscribe(ws);
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.get("/search", withErrorHandling(async (req, res) => {
|
||||
const query = req.query.q as string;
|
||||
if (!query) {
|
||||
res.status(400)
|
||||
.send(JSON.stringify({ success: false, error: "Query parameter 'q' is required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await searchInvidious(query);
|
||||
res.send(JSON.stringify({ success: true, results }));
|
||||
}));
|
||||
|
||||
apiRouter.get("/thumbnail", withErrorHandling(async (req, res) => {
|
||||
const thumbnailUrl = req.query.url as string;
|
||||
if (!thumbnailUrl) {
|
||||
res.status(400)
|
||||
.send(JSON.stringify({ success: false, error: "URL parameter is required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, contentType } = await fetchThumbnail(thumbnailUrl);
|
||||
res.set('Content-Type', contentType);
|
||||
data.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Failed to proxy thumbnail:', error);
|
||||
res.status(500)
|
||||
.send(JSON.stringify({ success: false, error: 'Failed to fetch thumbnail' }));
|
||||
}
|
||||
}));
|
||||
|
||||
apiRouter.get("/favorites", withErrorHandling(async (req, res) => {
|
||||
const favorites = await mediaPlayer.getFavorites();
|
||||
res.send(JSON.stringify(favorites));
|
||||
}));
|
||||
|
||||
apiRouter.post("/favorites", withErrorHandling(async (req, res) => {
|
||||
const item = req.body as PlaylistItem;
|
||||
await mediaPlayer.addFavorite(item);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/favorites/:id", withErrorHandling(async (req, res) => {
|
||||
const { id } = req.params;
|
||||
await mediaPlayer.removeFavorite(parseInt(id));
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/favorites", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.clearFavorites();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
// Serve static files for React app (after building)
|
||||
app.use(express.static("dist/frontend"));
|
||||
|
||||
// Mount API routes under /api
|
||||
app.use("/api", apiRouter);
|
||||
|
||||
// Serve React app for all other routes (client-side routing)
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile("dist/frontend/index.html", { root: "." });
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
});
|
||||
|
||||
// Add graceful shutdown handling
|
||||
const shutdown = async () => {
|
||||
console.log('Received shutdown signal. Closing server...');
|
||||
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force termination after some timeout (10sec)
|
||||
setTimeout(() => {
|
||||
console.log('Forcing server shutdown');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
// Handle various shutdown signals
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
@@ -1,14 +0,0 @@
|
||||
export interface LinkMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
id: number;
|
||||
filename: string;
|
||||
title?: string;
|
||||
playing?: boolean;
|
||||
current?: boolean;
|
||||
metadata?: LinkMetadata;
|
||||
}
|
||||
3673
frontend/package-lock.json
generated
3673
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,66 +0,0 @@
|
||||
import React, { HTMLAttributes } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp } from 'react-icons/fa';
|
||||
|
||||
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
||||
songName: string;
|
||||
fileName: string;
|
||||
isPlaying: boolean;
|
||||
volume: number;
|
||||
onPlayPause: () => void;
|
||||
onSkip: () => void;
|
||||
onPrevious: () => void;
|
||||
|
||||
// Sent when the volume setting actually changes value
|
||||
onVolumeSettingChange: (volume: number) => void;
|
||||
|
||||
// Sent when the volume is about to start changing
|
||||
onVolumeWillChange: (volume: number) => void;
|
||||
|
||||
// Sent when the volume has changed
|
||||
onVolumeDidChange: (volume: number) => void;
|
||||
}
|
||||
|
||||
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
||||
return (
|
||||
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<div className="flex flex-col md:flex-row w-full h-full bg-black/50 rounded-lg p-5 items-center gap-4">
|
||||
<div className="flex-grow min-w-0 w-full md:w-auto text-white">
|
||||
<div className="text-lg font-bold truncate text-center md:text-left">{props.songName}</div>
|
||||
<div className="text-sm truncate text-center md:text-left">{props.fileName}</div>
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-4 justify-center md:justify-end">
|
||||
<div className="flex items-center gap-2 text-white">
|
||||
<FaVolumeUp size={20} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={props.volume}
|
||||
onMouseDown={() => props.onVolumeWillChange(props.volume)}
|
||||
onMouseUp={() => props.onVolumeDidChange(props.volume)}
|
||||
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
|
||||
className="fancy-slider w-48 md:w-24 h-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
|
||||
<FaStepBackward size={24} />
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
|
||||
{props.isPlaying ? <FaPause size={24} /> : <FaPlay size={24} />}
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
|
||||
<FaStepForward size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NowPlaying;
|
||||
@@ -1,6 +0,0 @@
|
||||
export const USE_INVIDIOUS = import.meta.env.VITE_USE_INVIDIOUS || true;
|
||||
export const INVIDIOUS_BASE_URL = import.meta.env.VITE_INVIDIOUS_BASE_URL || 'http://invidious.nor';
|
||||
export const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
|
||||
|
||||
export const getInvidiousSearchURL = (query: string): string =>
|
||||
`${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`;
|
||||
1
frontend/src/vite-env.d.ts
vendored
1
frontend/src/vite-env.d.ts
vendored
@@ -1 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -1,18 +0,0 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
// For development only: proxy /api to backend running on separate port.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
93
ios/CLAUDE.md
Normal file
93
ios/CLAUDE.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Project Overview
|
||||
|
||||
QueueCube is a SwiftUI-based jukebox client application for iOS and macOS (via Mac Catalyst). It provides a frontend for controlling a server-based jukebox system that supports playlist management, favorites, and playback controls.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Core Components
|
||||
|
||||
- **API.swift**: Central networking layer that handles all communication with the jukebox server. Includes REST API methods for playback control, playlist management, and WebSocket events for real-time updates.
|
||||
- **ContentView.swift**: Main view controller containing the `MainViewModel` that coordinates between UI components and API calls. Handles WebSocket event processing and data flow.
|
||||
- **Server.swift**: Represents individual jukebox servers with support for both manual configuration and Bonjour service discovery.
|
||||
- **Settings.swift**: Manages multiple server configurations stored in UserDefaults with validation through `SettingsViewModel` that tests connectivity on URL changes.
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **Multiple Server Support**: Array of `Server` objects stored in UserDefaults with selected server tracking
|
||||
2. **Settings**: Server configurations validated asynchronously via API calls with live connectivity testing
|
||||
3. **Real-time Updates**: WebSocket connection provides live updates for playlist changes, playback state, and volume
|
||||
4. **API Integration**: All server communication goes through the `API` struct using a fluent `RequestBuilder` pattern
|
||||
5. **State Management**: Uses SwiftUI's `@Observable` pattern for reactive UI updates
|
||||
|
||||
### Request Builder Pattern
|
||||
|
||||
The API layer uses a fluent builder pattern for HTTP requests:
|
||||
```swift
|
||||
try await request()
|
||||
.path("/nowplaying")
|
||||
.json()
|
||||
```
|
||||
|
||||
This provides type-safe, composable API calls with automatic error handling and connection state management.
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Real-time sync**: WebSocket events automatically refresh UI when server state changes
|
||||
- **Cross-platform**: Supports iOS, iPadOS, and macOS via Mac Catalyst
|
||||
- **Settings validation**: Live server connectivity testing with visual feedback
|
||||
- **Error handling**: Connection state management with user-friendly error displays
|
||||
|
||||
## Development Commands
|
||||
|
||||
### Building
|
||||
```bash
|
||||
# Build for iOS Simulator
|
||||
xcodebuild -project QueueCube.xcodeproj -scheme QueueCube -destination 'platform=iOS Simulator,name=iPhone 15' build
|
||||
|
||||
# Build for Mac Catalyst
|
||||
xcodebuild -project QueueCube.xcodeproj -scheme QueueCube -destination 'platform=macOS,variant=Mac Catalyst' build
|
||||
```
|
||||
|
||||
### Running
|
||||
- Open `QueueCube.xcodeproj` in Xcode
|
||||
- Select target device (iOS Simulator or Mac)
|
||||
- Run with Cmd+R
|
||||
|
||||
## API Endpoints Reference
|
||||
|
||||
The server API includes these endpoints:
|
||||
- `GET /nowplaying` - Current playback status
|
||||
- `GET /playlist` - Current playlist items
|
||||
- `GET /favorites` - User favorites
|
||||
- `POST /play`, `/pause`, `/skip`, `/previous` - Playback controls
|
||||
- `POST /playlist` - Add media URL to playlist
|
||||
- `DELETE /playlist/{index}` - Remove playlist item
|
||||
- `POST /volume` - Set volume level
|
||||
- `WS /events` - WebSocket for real-time updates
|
||||
|
||||
## UI Structure
|
||||
|
||||
### View Hierarchy
|
||||
```
|
||||
QueueCubeApp
|
||||
└── ContentView (coordination layer)
|
||||
└── MainView (tab management)
|
||||
├── PlaylistView (with embedded NowPlayingView)
|
||||
├── FavoritesView (favorites management)
|
||||
└── SettingsView (server configuration)
|
||||
├── ServerListSettingsView
|
||||
├── AddServerView
|
||||
└── GeneralSettingsView
|
||||
```
|
||||
|
||||
### Key Views
|
||||
- **ContentView**: Main coordinator that manages API instances and global state
|
||||
- **MainView**: Tab-based navigation container with platform-specific adaptations
|
||||
- **PlaylistView**: Scrollable list of queued media with reorder/delete actions, includes embedded NowPlayingView
|
||||
- **NowPlayingView**: Playback controls and current track display
|
||||
- **AddMediaBarView**: Input field for adding new media URLs to playlist
|
||||
- **SettingsView**: Multi-server configuration with live validation and service discovery
|
||||
358
ios/QueueCube.xcodeproj/project.pbxproj
Normal file
358
ios/QueueCube.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,358 @@
|
||||
// !$*UTF8*$!
|
||||
{
|
||||
archiveVersion = 1;
|
||||
classes = {
|
||||
};
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
CD4E9B972D7691C20066FC17 /* QueueCube.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = QueueCube.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */ = {
|
||||
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
|
||||
membershipExceptions = (
|
||||
App/Info.plist,
|
||||
);
|
||||
target = CD4E9B962D7691C20066FC17 /* QueueCube */;
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedRootGroup section */
|
||||
CD4E9B992D7691C20066FC17 /* QueueCube */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
CD8ACBBF2DC5B8F2008BF856 /* Exceptions for "QueueCube" folder in "QueueCube" target */,
|
||||
);
|
||||
path = QueueCube;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
CD4E9B942D7691C20066FC17 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
CD4E9B8E2D7691C20066FC17 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD4E9B992D7691C20066FC17 /* QueueCube */,
|
||||
CD4E9B982D7691C20066FC17 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
CD4E9B982D7691C20066FC17 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
CD4E9B972D7691C20066FC17 /* QueueCube.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
CD4E9B962D7691C20066FC17 /* QueueCube */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = CD4E9BA22D7691C40066FC17 /* Build configuration list for PBXNativeTarget "QueueCube" */;
|
||||
buildPhases = (
|
||||
CD4E9B932D7691C20066FC17 /* Sources */,
|
||||
CD4E9B942D7691C20066FC17 /* Frameworks */,
|
||||
CD4E9B952D7691C20066FC17 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
CD4E9B992D7691C20066FC17 /* QueueCube */,
|
||||
);
|
||||
name = QueueCube;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = QueueCube;
|
||||
productReference = CD4E9B972D7691C20066FC17 /* QueueCube.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
CD4E9B8F2D7691C20066FC17 /* Project object */ = {
|
||||
isa = PBXProject;
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = 1;
|
||||
LastSwiftUpdateCheck = 1700;
|
||||
LastUpgradeCheck = 1700;
|
||||
TargetAttributes = {
|
||||
CD4E9B962D7691C20066FC17 = {
|
||||
CreatedOnToolsVersion = 17.0;
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = CD4E9B922D7691C20066FC17 /* Build configuration list for PBXProject "QueueCube" */;
|
||||
developmentRegion = en;
|
||||
hasScannedForEncodings = 0;
|
||||
knownRegions = (
|
||||
en,
|
||||
Base,
|
||||
);
|
||||
mainGroup = CD4E9B8E2D7691C20066FC17;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = CD4E9B982D7691C20066FC17 /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
CD4E9B962D7691C20066FC17 /* QueueCube */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
CD4E9B952D7691C20066FC17 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
CD4E9B932D7691C20066FC17 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
CD4E9BA02D7691C40066FC17 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_TESTABILITY = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_DYNAMIC_NO_PIC = NO;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_OPTIMIZATION_LEVEL = 0;
|
||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||
"DEBUG=1",
|
||||
"$(inherited)",
|
||||
);
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 19.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CD4E9BA12D7691C40066FC17 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||
ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES;
|
||||
CLANG_ANALYZER_NONNULL = YES;
|
||||
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||
CLANG_CXX_LANGUAGE_STANDARD = "gnu++20";
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CLANG_ENABLE_OBJC_ARC = YES;
|
||||
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||
CLANG_WARN_COMMA = YES;
|
||||
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||
CLANG_WARN_EMPTY_BODY = YES;
|
||||
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||
CLANG_WARN_INT_CONVERSION = YES;
|
||||
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||
COPY_PHASE_STRIP = NO;
|
||||
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||
ENABLE_NS_ASSERTIONS = NO;
|
||||
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GCC_NO_COMMON_BLOCKS = YES;
|
||||
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 19.0;
|
||||
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
CD4E9BA32D7691C40066FC17 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = QueueCube/App/Entitlements.plist;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = QueueCube/App/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
CD4E9BA42D7691C40066FC17 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = QueueCube/App/Entitlements.plist;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 5;
|
||||
DEVELOPMENT_TEAM = DQQH5H6GBD;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = QueueCube/App/Info.plist;
|
||||
INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES;
|
||||
INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES;
|
||||
INFOPLIST_KEY_UILaunchScreen_Generation = YES;
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.2;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = net.buzzert.QueueCube;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO;
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
SWIFT_VERSION = 5.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
/* End XCBuildConfiguration section */
|
||||
|
||||
/* Begin XCConfigurationList section */
|
||||
CD4E9B922D7691C20066FC17 /* Build configuration list for PBXProject "QueueCube" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CD4E9BA02D7691C40066FC17 /* Debug */,
|
||||
CD4E9BA12D7691C40066FC17 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
CD4E9BA22D7691C40066FC17 /* Build configuration list for PBXNativeTarget "QueueCube" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
CD4E9BA32D7691C40066FC17 /* Debug */,
|
||||
CD4E9BA42D7691C40066FC17 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = CD4E9B8F2D7691C20066FC17 /* Project object */;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Scheme
|
||||
LastUpgradeVersion = "1700"
|
||||
version = "1.7">
|
||||
<BuildAction
|
||||
parallelizeBuildables = "YES"
|
||||
buildImplicitDependencies = "YES"
|
||||
buildArchitectures = "Automatic">
|
||||
<BuildActionEntries>
|
||||
<BuildActionEntry
|
||||
buildForTesting = "YES"
|
||||
buildForRunning = "YES"
|
||||
buildForProfiling = "YES"
|
||||
buildForArchiving = "YES"
|
||||
buildForAnalyzing = "YES">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CD4E9B962D7691C20066FC17"
|
||||
BuildableName = "QueueCube.app"
|
||||
BlueprintName = "QueueCube"
|
||||
ReferencedContainer = "container:QueueCube.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildActionEntry>
|
||||
</BuildActionEntries>
|
||||
</BuildAction>
|
||||
<TestAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
shouldAutocreateTestPlan = "YES">
|
||||
</TestAction>
|
||||
<LaunchAction
|
||||
buildConfiguration = "Debug"
|
||||
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
|
||||
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
|
||||
launchStyle = "0"
|
||||
useCustomWorkingDirectory = "NO"
|
||||
ignoresPersistentStateOnLaunch = "NO"
|
||||
debugDocumentVersioning = "YES"
|
||||
debugServiceExtension = "internal"
|
||||
allowLocationSimulation = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CD4E9B962D7691C20066FC17"
|
||||
BuildableName = "QueueCube.app"
|
||||
BlueprintName = "QueueCube"
|
||||
ReferencedContainer = "container:QueueCube.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</LaunchAction>
|
||||
<ProfileAction
|
||||
buildConfiguration = "Release"
|
||||
shouldUseLaunchSchemeArgsEnv = "YES"
|
||||
savedToolIdentifier = ""
|
||||
useCustomWorkingDirectory = "NO"
|
||||
debugDocumentVersioning = "YES">
|
||||
<BuildableProductRunnable
|
||||
runnableDebuggingMode = "0">
|
||||
<BuildableReference
|
||||
BuildableIdentifier = "primary"
|
||||
BlueprintIdentifier = "CD4E9B962D7691C20066FC17"
|
||||
BuildableName = "QueueCube.app"
|
||||
BlueprintName = "QueueCube"
|
||||
ReferencedContainer = "container:QueueCube.xcodeproj">
|
||||
</BuildableReference>
|
||||
</BuildableProductRunnable>
|
||||
</ProfileAction>
|
||||
<AnalyzeAction
|
||||
buildConfiguration = "Debug">
|
||||
</AnalyzeAction>
|
||||
<ArchiveAction
|
||||
buildConfiguration = "Release"
|
||||
revealArchiveInOrganizer = "YES">
|
||||
</ArchiveAction>
|
||||
<InstallAction
|
||||
buildConfiguration = "Release">
|
||||
</InstallAction>
|
||||
</Scheme>
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
ios/QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
BIN
ios/QueueCube/App/Assets.xcassets/AppIcon.appiconset/AppIcon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 574 KiB |
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AppIcon.png",
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "tinted"
|
||||
}
|
||||
],
|
||||
"idiom" : "universal",
|
||||
"platform" : "ios",
|
||||
"size" : "1024x1024"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
ios/QueueCube/App/Assets.xcassets/Contents.json
Normal file
6
ios/QueueCube/App/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
5
ios/QueueCube/App/Entitlements.plist
Normal file
5
ios/QueueCube/App/Entitlements.plist
Normal file
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict/>
|
||||
</plist>
|
||||
17
ios/QueueCube/App/Info.plist
Normal file
17
ios/QueueCube/App/Info.plist
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_queuecube._tcp.</string>
|
||||
</array>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>QueueCube needs access to your local network to discover nearby jukebox servers.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
44
ios/QueueCube/App/QueueCubeApp.swift
Normal file
44
ios/QueueCube/App/QueueCubeApp.swift
Normal file
@@ -0,0 +1,44 @@
|
||||
//
|
||||
// QueueCubeApp.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 3/3/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct QueueCubeApp: App {
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
.onAppear {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene else { return }
|
||||
windowScene.titlebar?.titleVisibility = .hidden
|
||||
windowScene.titlebar?.separatorStyle = .none
|
||||
#endif
|
||||
}
|
||||
}.commands {
|
||||
CommandGroup(replacing: .appSettings) {
|
||||
Button(.settings_) {
|
||||
openWindow(id: .settingsWindowID)
|
||||
}
|
||||
.keyboardShortcut(",", modifiers: .command)
|
||||
}
|
||||
}
|
||||
.defaultSize(width: 640.0, height: 800.0)
|
||||
|
||||
WindowGroup(id: .settingsWindowID) {
|
||||
SettingsView(onDone: {})
|
||||
}
|
||||
.defaultSize(width: 480.0, height: 400.0)
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate extension String
|
||||
{
|
||||
static let settingsWindowID = "settings"
|
||||
}
|
||||
289
ios/QueueCube/Backend/API.swift
Normal file
289
ios/QueueCube/Backend/API.swift
Normal file
@@ -0,0 +1,289 @@
|
||||
//
|
||||
// API.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 3/3/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct MediaItem: Codable
|
||||
{
|
||||
let filename: String?
|
||||
let title: String?
|
||||
let id: Int
|
||||
|
||||
let current: Bool?
|
||||
let playing: Bool?
|
||||
let metadata: Metadata?
|
||||
|
||||
var displayTitle: String {
|
||||
metadata?.title ?? title ?? filename ?? "item \(id)"
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
struct Metadata: Codable
|
||||
{
|
||||
let title: String?
|
||||
let description: String?
|
||||
let siteName: String?
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResultItem: Codable
|
||||
{
|
||||
var type: String
|
||||
var title: String
|
||||
var author: String
|
||||
var mediaUrl: String
|
||||
var thumbnailUrl: String
|
||||
}
|
||||
|
||||
struct FetchResult<T: Codable>: Codable
|
||||
{
|
||||
let success: Bool
|
||||
let results: T?
|
||||
let error: String?
|
||||
}
|
||||
|
||||
struct NowPlayingInfo: Codable
|
||||
{
|
||||
let playingItem: MediaItem?
|
||||
let isPaused: Bool
|
||||
let volume: Int
|
||||
}
|
||||
|
||||
actor API
|
||||
{
|
||||
let baseURL: URL
|
||||
|
||||
private var pingTask: Task<(), any Swift.Error>? = nil
|
||||
|
||||
init(baseURL: URL) {
|
||||
self.baseURL = baseURL
|
||||
}
|
||||
|
||||
public func fetchNowPlayingInfo() async throws -> NowPlayingInfo {
|
||||
try await request()
|
||||
.path("/nowplaying")
|
||||
.json()
|
||||
}
|
||||
|
||||
public func fetchPlaylist() async throws -> [MediaItem] {
|
||||
try await request()
|
||||
.path("/playlist")
|
||||
.json()
|
||||
}
|
||||
|
||||
public func fetchFavorites() async throws -> [MediaItem] {
|
||||
try await request()
|
||||
.path("/favorites")
|
||||
.json()
|
||||
}
|
||||
|
||||
public func play() async throws {
|
||||
try await request()
|
||||
.path("/play")
|
||||
.post()
|
||||
}
|
||||
|
||||
public func pause() async throws {
|
||||
try await request()
|
||||
.path("/pause")
|
||||
.post()
|
||||
}
|
||||
|
||||
public func stop() async throws {
|
||||
try await request()
|
||||
.path("/stop")
|
||||
.post()
|
||||
}
|
||||
|
||||
public func skip(_ to: Int? = nil) async throws {
|
||||
let path = if let to { "/skip/\(to)" } else { "/skip" }
|
||||
try await request()
|
||||
.path(path)
|
||||
.post()
|
||||
}
|
||||
|
||||
public func previous() async throws {
|
||||
try await request()
|
||||
.path("/previous")
|
||||
.post()
|
||||
}
|
||||
|
||||
public func add(mediaURL: String) async throws {
|
||||
try await request()
|
||||
.path("/playlist")
|
||||
.body([ "url" : mediaURL ])
|
||||
.post()
|
||||
}
|
||||
|
||||
public func replace(mediaURL: String) async throws {
|
||||
try await request()
|
||||
.path("/playlist/replace")
|
||||
.body([ "url" : mediaURL ])
|
||||
.post()
|
||||
}
|
||||
|
||||
public func addFavorite(mediaURL: String) async throws {
|
||||
try await request()
|
||||
.path("/favorites")
|
||||
.body([ "filename" : mediaURL ])
|
||||
.post()
|
||||
}
|
||||
|
||||
public func deleteFavorite(mediaURL: String) async throws {
|
||||
try await request()
|
||||
.pathString("/favorites/\(mediaURL.uriEncoded())")
|
||||
.method(.delete)
|
||||
.execute()
|
||||
}
|
||||
|
||||
public func renameFavorite(mediaURL: String, title: String) async throws {
|
||||
try await request()
|
||||
.pathString("/favorites/\(mediaURL.uriEncoded())/title")
|
||||
.body([ "title": title ])
|
||||
.method(.put)
|
||||
.execute()
|
||||
}
|
||||
|
||||
public func delete(index: Int) async throws {
|
||||
try await request()
|
||||
.path("/playlist/\(index)")
|
||||
.method(.delete)
|
||||
.execute()
|
||||
}
|
||||
|
||||
public func setVolume(_ value: Double) async throws {
|
||||
try await request()
|
||||
.path("/volume")
|
||||
.body([ "volume" : Int(value * 100) ])
|
||||
.post()
|
||||
}
|
||||
|
||||
public func search(query: String) async throws -> FetchResult<[SearchResultItem]> {
|
||||
try await request()
|
||||
.pathString("/search?q=\(query.uriEncoded())")
|
||||
.json()
|
||||
}
|
||||
|
||||
public func events() async throws -> AsyncStream<StreamEvent> {
|
||||
let requestBuilder: () -> RequestBuilder = request
|
||||
|
||||
return AsyncStream { continuation in
|
||||
let websocketTask: URLSessionWebSocketTask = API.spawnWebsocketTask(requestBuilder: requestBuilder, with: continuation)
|
||||
|
||||
Task {
|
||||
var pingLoopEnabled = true
|
||||
while pingLoopEnabled {
|
||||
try await Task.sleep(for: .seconds(5))
|
||||
|
||||
websocketTask.sendPing { error in
|
||||
if let error {
|
||||
API.notifyError(error, continuation: continuation)
|
||||
pingLoopEnabled = false
|
||||
} else {
|
||||
continuation.yield(.event(Event(type: .receivedWebsocketPong)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func spawnWebsocketTask(
|
||||
requestBuilder: () -> RequestBuilder,
|
||||
with continuation: AsyncStream<StreamEvent>.Continuation
|
||||
) -> URLSessionWebSocketTask
|
||||
{
|
||||
let url = requestBuilder()
|
||||
.path("/events")
|
||||
.websocket()
|
||||
|
||||
let websocketTask = URLSession.shared.webSocketTask(with: url)
|
||||
websocketTask.resume()
|
||||
|
||||
Task {
|
||||
do {
|
||||
let event = { (data: Data) in
|
||||
try JSONDecoder().decode(Event.self, from: data)
|
||||
}
|
||||
|
||||
while websocketTask.state == .running {
|
||||
switch try await websocketTask.receive() {
|
||||
case .string(let string):
|
||||
let event = try event(string.data(using: .utf8)!)
|
||||
continuation.yield(.event(event))
|
||||
case .data(let data):
|
||||
let event = try event(data)
|
||||
continuation.yield(.event(event))
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
notifyError(error, continuation: continuation)
|
||||
}
|
||||
}
|
||||
|
||||
return websocketTask
|
||||
}
|
||||
|
||||
private static func notifyError(_ error: any Swift.Error, continuation: AsyncStream<StreamEvent>.Continuation) {
|
||||
print("Websocket Error: \(error)")
|
||||
|
||||
let nsError = error as NSError
|
||||
|
||||
// Always notify observers of WebSocket errors so reconnection can happen
|
||||
// The UI layer can decide whether to show the error to the user
|
||||
continuation.yield(.error(.websocketError(error)))
|
||||
}
|
||||
|
||||
private func request() -> RequestBuilder {
|
||||
RequestBuilder(url: baseURL)
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Error: Swift.Error
|
||||
{
|
||||
case apiNotConfigured
|
||||
case websocketError(Swift.Error)
|
||||
}
|
||||
|
||||
enum StreamEvent {
|
||||
case event(Event)
|
||||
case error(API.Error)
|
||||
}
|
||||
|
||||
struct Event: Decodable
|
||||
{
|
||||
let type: EventType
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case type = "event"
|
||||
}
|
||||
|
||||
enum EventType: String, Decodable {
|
||||
case playlistUpdate = "playlist_update"
|
||||
case nowPlayingUpdate = "now_playing_update"
|
||||
case volumeUpdate = "volume_update"
|
||||
case favoritesUpdate = "favorites_update"
|
||||
case metadataUpdate = "metadata_update"
|
||||
case mpdUpdate = "mpd_update"
|
||||
|
||||
// Private UI events
|
||||
case receivedWebsocketPong
|
||||
case websocketReconnected
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension String
|
||||
{
|
||||
func uriEncoded() -> Self {
|
||||
return addingPercentEncoding(withAllowedCharacters: .urlHostAllowed)!
|
||||
}
|
||||
}
|
||||
57
ios/QueueCube/Backend/Server.swift
Normal file
57
ios/QueueCube/Backend/Server.swift
Normal file
@@ -0,0 +1,57 @@
|
||||
//
|
||||
// Server.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Server: Identifiable, Codable, Equatable
|
||||
{
|
||||
let serviceName: String?
|
||||
let baseURL: URL
|
||||
|
||||
var id: String { baseURL.absoluteString }
|
||||
|
||||
var api: API { API(baseURL: baseURL) }
|
||||
|
||||
var displayName: String {
|
||||
if let serviceName {
|
||||
return serviceName.queueCubeServiceName
|
||||
}
|
||||
|
||||
let components = URLComponents(url: baseURL, resolvingAgainstBaseURL: false)!
|
||||
return components.host ?? baseURL.absoluteString
|
||||
}
|
||||
|
||||
init?(serviceName: String?, host: String, port: UInt16) {
|
||||
self.serviceName = serviceName
|
||||
|
||||
// Assumes this is the local service discovery path, which is http
|
||||
// Bounjour gives us the interface sometimes, which we can handle, but need to percent encode.
|
||||
let host = host.replacingOccurrences(of: "%", with: "%25")
|
||||
guard let url = URL(string: "http://\(host):\(port)/api") else {
|
||||
return nil
|
||||
}
|
||||
|
||||
self.baseURL = url
|
||||
}
|
||||
|
||||
init(baseURL: URL) {
|
||||
self.serviceName = nil
|
||||
self.baseURL = baseURL
|
||||
}
|
||||
}
|
||||
|
||||
extension String
|
||||
{
|
||||
var queueCubeServiceName: String {
|
||||
let regex = /.* \((.*)\)/
|
||||
if let match = try? regex.firstMatch(in: self) {
|
||||
return String(match.output.1)
|
||||
}
|
||||
|
||||
return self
|
||||
}
|
||||
}
|
||||
117
ios/QueueCube/Backend/Settings.swift
Normal file
117
ios/QueueCube/Backend/Settings.swift
Normal file
@@ -0,0 +1,117 @@
|
||||
//
|
||||
// Settings.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Settings
|
||||
{
|
||||
var selectedServer: Server?
|
||||
|
||||
var configuredServers: [Server] {
|
||||
willSet {
|
||||
// Set selected server to whatever the first server is, if we're adding the first one.
|
||||
if configuredServers.isEmpty && !newValue.isEmpty && selectedServer == nil {
|
||||
selectedServer = newValue.first
|
||||
}
|
||||
|
||||
// If the selected server is being removed, set it to something else
|
||||
if !newValue.contains(where: { $0 == selectedServer }) {
|
||||
selectedServer = newValue.first // nil if empty
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var isConfigured: Bool { !configuredServers.isEmpty }
|
||||
|
||||
static func fromDefaults() -> Settings {
|
||||
let defaults = UserDefaults.standard
|
||||
return Settings(
|
||||
selectedServer: defaults[SelectedServerKey.self],
|
||||
configuredServers: defaults[ConfiguredServersKey.self]
|
||||
)
|
||||
}
|
||||
|
||||
func save() {
|
||||
let defaults = UserDefaults.standard
|
||||
defaults[ConfiguredServersKey.self] = configuredServers
|
||||
defaults[SelectedServerKey.self] = selectedServer
|
||||
|
||||
postSettingsChanged()
|
||||
}
|
||||
|
||||
func postSettingsChanged() {
|
||||
NotificationCenter.default.post(name: .settingsChanged, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Modifiers
|
||||
|
||||
func selectedServer(_ server: Server?) -> Self {
|
||||
var copy = self
|
||||
copy.selectedServer = server
|
||||
return copy
|
||||
}
|
||||
|
||||
func configuredServers(_ servers: [Server]) -> Self {
|
||||
var copy = self
|
||||
copy.configuredServers = servers
|
||||
return copy
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Keys: String
|
||||
{
|
||||
case selectedServer
|
||||
case configuredServers
|
||||
}
|
||||
|
||||
fileprivate protocol Key
|
||||
{
|
||||
associatedtype Value: Codable
|
||||
|
||||
static var defaultValue: Value { get }
|
||||
static var key: String { get }
|
||||
}
|
||||
|
||||
private struct ConfiguredServersKey: Key {
|
||||
static var defaultValue: [Server] { [] }
|
||||
}
|
||||
|
||||
private struct SelectedServerKey: Key {
|
||||
static var defaultValue: Server? { nil }
|
||||
}
|
||||
}
|
||||
|
||||
extension UserDefaults
|
||||
{
|
||||
fileprivate subscript<T: Settings.Key>(_ type: T.Type) -> T.Value {
|
||||
get {
|
||||
guard let data = data(forKey: type.key)
|
||||
else { return type.defaultValue }
|
||||
|
||||
guard let value = try? PropertyListDecoder().decode(type.Value, from: data)
|
||||
else { return type.defaultValue }
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
set {
|
||||
let data = try? PropertyListEncoder().encode(newValue)
|
||||
set(data, forKey: type.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Settings.Key
|
||||
{
|
||||
static var key: String { Mirror(reflecting: Self.self).description }
|
||||
}
|
||||
|
||||
extension Notification.Name
|
||||
{
|
||||
static let settingsChanged = Notification.Name("settingsChanged")
|
||||
}
|
||||
101
ios/QueueCube/Backend/Utilities.swift
Normal file
101
ios/QueueCube/Backend/Utilities.swift
Normal file
@@ -0,0 +1,101 @@
|
||||
//
|
||||
// Utilities.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 3/3/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension Optional
|
||||
{
|
||||
func try_unwrap() throws -> Wrapped {
|
||||
guard let self else { throw UnwrapError() }
|
||||
return self
|
||||
}
|
||||
|
||||
struct UnwrapError: Swift.Error {}
|
||||
}
|
||||
|
||||
struct RequestBuilder
|
||||
{
|
||||
let url: URL
|
||||
private var httpMethod: HTTPMethod = .get
|
||||
private var body: Data? = nil
|
||||
|
||||
init(url: URL) {
|
||||
self.url = url
|
||||
}
|
||||
|
||||
public func method(_ method: HTTPMethod) -> Self {
|
||||
var copy = self
|
||||
copy.httpMethod = method
|
||||
return copy
|
||||
}
|
||||
|
||||
public func path(_ path: any StringProtocol) -> Self {
|
||||
return RequestBuilder(url: self.url.appending(path: path))
|
||||
}
|
||||
|
||||
public func pathString(_ pathString: any StringProtocol) -> Self {
|
||||
// xxx: should just fix DELETE /favorites/:filename: instead.
|
||||
return RequestBuilder(url: URL(string: self.url.absoluteString + pathString)!)
|
||||
}
|
||||
|
||||
public func body(_ data: Codable) -> Self {
|
||||
var copy = self
|
||||
copy.body = try! JSONEncoder().encode(data)
|
||||
return copy
|
||||
}
|
||||
|
||||
public func build() -> URLRequest {
|
||||
var request = URLRequest(url: self.url)
|
||||
request.httpMethod = self.httpMethod.rawValue
|
||||
if let body {
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = body
|
||||
}
|
||||
|
||||
return request
|
||||
}
|
||||
|
||||
public func json<T: Decodable>() async throws -> T {
|
||||
let urlRequest = self.build()
|
||||
let (data, _) = try await URLSession.shared.data(for: urlRequest)
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
public func post() async throws {
|
||||
try await self.method(.post).execute()
|
||||
}
|
||||
|
||||
public func execute() async throws {
|
||||
let urlRequest = self.build()
|
||||
let (data, response) = try await URLSession.shared.data(for: urlRequest)
|
||||
if let httpResponse = response as? HTTPURLResponse {
|
||||
if httpResponse.statusCode != 200 {
|
||||
print("POST error \(httpResponse.statusCode): \(String(data: data, encoding: .utf8)!)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func websocket() -> URL {
|
||||
guard var components = URLComponents(url: self.url, resolvingAgainstBaseURL: false) else { fatalError() }
|
||||
components.scheme = components.scheme == "https" ? "wss" : "ws"
|
||||
components.host = components.host!.replacing(/\%(.*)$/, with: "")
|
||||
return components.url!
|
||||
}
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case get = "GET"
|
||||
case put = "PUT"
|
||||
case post = "POST"
|
||||
case delete = "DELETE"
|
||||
}
|
||||
}
|
||||
|
||||
extension Color
|
||||
{
|
||||
static let label = Color(uiColor: .label)
|
||||
}
|
||||
385
ios/QueueCube/Localizable/Localizable.xcstrings
Normal file
385
ios/QueueCube/Localizable/Localizable.xcstrings
Normal file
@@ -0,0 +1,385 @@
|
||||
{
|
||||
"sourceLanguage" : "en",
|
||||
"strings" : {
|
||||
"%@" : {
|
||||
|
||||
},
|
||||
"ADD" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Add"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ADD_ANY_URL" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Add any URL…"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ADD_MEDIA" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Add Media"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ADD_SERVER" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Add Server"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ADD_TO_QUEUE" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Add to Queue"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CANCEL" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CONFIGURATION" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Configuration"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CONNECTION_ERROR" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Connection Error"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"COPY_TITLE" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Copy Title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"COPY_URL" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Copy URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DISCOVERED" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Discovered"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"DONE" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Done"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"EDIT" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Edit…"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"EDIT_ITEM" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Edit Item"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ENTER_MANUALLY" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Enter Manually"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FAVORITE" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Favorite"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FAVORITES" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Favorites"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FAVORITES_IS_EMPTY" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Favorites is empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"FINDING_SERVERS" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Finding Servers…"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"GENERAL" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "General"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NO_RESULTS_FOUND" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "No Results Found"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NO_SERVERS_CONFIGURED" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "No Servers Configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NOT_CONFIGURED" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Not Configured"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"NOT_PLAYING" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Not Playing"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Nothing here yet." : {
|
||||
|
||||
},
|
||||
"PLAYLIST" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Playlist"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"PLAYLIST_IS_EMPTY" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Playlist is empty"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SEARCH_FOR_MEDIA" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Search YouTube for Media…"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SEARCHING_" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Searching…"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SERVER_IS_ONLINE" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Server is online"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SERVER_URL" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Server URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SERVERS" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Servers"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SETTINGS" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Settings"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"SETTINGS_ELLIPSES" : {
|
||||
"extractionState" : "manual",
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Settings…"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"TITLE" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Title"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"UNABLE_TO_CONNECT" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Unable to connect"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"URL" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "URL"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"VALIDATING" : {
|
||||
"localizations" : {
|
||||
"en" : {
|
||||
"stringUnit" : {
|
||||
"state" : "translated",
|
||||
"value" : "Validating…"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"version" : "1.0"
|
||||
}
|
||||
49
ios/QueueCube/Localizable/Strings.swift
Normal file
49
ios/QueueCube/Localizable/Strings.swift
Normal file
@@ -0,0 +1,49 @@
|
||||
//
|
||||
// Strings.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 5/2/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension LocalizedStringKey
|
||||
{
|
||||
static let serverURL = LocalizedStringKey("SERVER_URL")
|
||||
static let settings = LocalizedStringKey("SETTINGS")
|
||||
static let settings_ = LocalizedStringKey("SETTINGS_ELLIPSES")
|
||||
static let done = LocalizedStringKey("DONE")
|
||||
static let notConfigured = LocalizedStringKey("NOT_CONFIGURED")
|
||||
static let add = LocalizedStringKey("ADD")
|
||||
static let addAnyURL = LocalizedStringKey("ADD_ANY_URL")
|
||||
static let serverIsOnline = LocalizedStringKey("SERVER_IS_ONLINE")
|
||||
static let unableToConnect = LocalizedStringKey("UNABLE_TO_CONNECT")
|
||||
static let configuration = LocalizedStringKey("CONFIGURATION")
|
||||
static let validating = LocalizedStringKey("VALIDATING")
|
||||
static let general = LocalizedStringKey("GENERAL")
|
||||
static let connectionError = LocalizedStringKey("CONNECTION_ERROR")
|
||||
static let playlist = LocalizedStringKey("PLAYLIST")
|
||||
static let favorites = LocalizedStringKey("FAVORITES")
|
||||
static let favorite = LocalizedStringKey("FAVORITE")
|
||||
static let servers = LocalizedStringKey("SERVERS")
|
||||
static let addServer = LocalizedStringKey("ADD_SERVER")
|
||||
static let cancel = LocalizedStringKey("CANCEL")
|
||||
static let manual = LocalizedStringKey("ENTER_MANUALLY")
|
||||
static let discovered = LocalizedStringKey("DISCOVERED")
|
||||
static let findingServers = LocalizedStringKey("FINDING_SERVERS")
|
||||
static let noServersConfigured = LocalizedStringKey("NO_SERVERS_CONFIGURED")
|
||||
static let playlistEmpty = LocalizedStringKey("PLAYLIST_IS_EMPTY")
|
||||
static let favoritesEmpty = LocalizedStringKey("FAVORITES_IS_EMPTY")
|
||||
static let addMedia = LocalizedStringKey("ADD_MEDIA")
|
||||
static let searchForMedia = LocalizedStringKey("SEARCH_FOR_MEDIA")
|
||||
static let searching = LocalizedStringKey("SEARCHING_")
|
||||
static let noResultsFound = LocalizedStringKey("NO_RESULTS_FOUND")
|
||||
static let copyTitle = LocalizedStringKey("COPY_TITLE")
|
||||
static let copyURL = LocalizedStringKey("COPY_URL")
|
||||
static let edit = LocalizedStringKey("EDIT")
|
||||
static let editItem = LocalizedStringKey("EDIT_ITEM")
|
||||
static let addToQueue = LocalizedStringKey("ADD_TO_QUEUE")
|
||||
static let notPlaying = LocalizedStringKey("NOT_PLAYING")
|
||||
static let url = LocalizedStringKey("URL")
|
||||
static let title = LocalizedStringKey("TITLE")
|
||||
}
|
||||
284
ios/QueueCube/Views/AddMediaView.swift
Normal file
284
ios/QueueCube/Views/AddMediaView.swift
Normal file
@@ -0,0 +1,284 @@
|
||||
//
|
||||
// AddMediaView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/11/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct AddMediaView: View
|
||||
{
|
||||
@Binding var model: ViewModel
|
||||
@FocusState var fieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
// Add URL
|
||||
Section {
|
||||
TextField(.addAnyURL, text: $model.fieldContents)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled()
|
||||
.focused($fieldFocused)
|
||||
}
|
||||
|
||||
if model.supportsSearch {
|
||||
Section {
|
||||
NavigationLink {
|
||||
SearchMediaView(model: $model)
|
||||
} label: {
|
||||
Image(systemName: "magnifyingglass")
|
||||
Button(.searchForMedia, action: model.onSearch)
|
||||
}
|
||||
.tint(.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
.task { fieldFocused = true }
|
||||
.onAppear { model.activeDetent = ViewModel.Detent.collapsed.value }
|
||||
.navigationTitle(.addMedia)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button(.add, action: model.addButtonTapped)
|
||||
.disabled(model.fieldContents.isEmpty)
|
||||
.bold()
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
Button(.cancel, action: model.onCancel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Page: String, Identifiable
|
||||
{
|
||||
case addURL
|
||||
case searchMedia
|
||||
|
||||
var id: String { rawValue }
|
||||
}
|
||||
|
||||
@Observable
|
||||
class ViewModel
|
||||
{
|
||||
var fieldContents: String = ""
|
||||
var onAdd: (String) -> Void = { _ in }
|
||||
var onCancel: () -> Void = { }
|
||||
var onSearch: () -> Void = { }
|
||||
var supportsSearch: Bool = true
|
||||
|
||||
var activeDetent: PresentationDetent = Detent.collapsed.value
|
||||
|
||||
enum Detent: CaseIterable
|
||||
{
|
||||
case collapsed
|
||||
case expanded
|
||||
|
||||
var value: PresentationDetent {
|
||||
switch self {
|
||||
case .collapsed: .height(320.0)
|
||||
case .expanded: .large
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fileprivate func addButtonTapped() {
|
||||
onAdd(fieldContents)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchMediaView: View
|
||||
{
|
||||
@Binding var model: AddMediaView.ViewModel
|
||||
@State private var searchModel = SearchModel()
|
||||
@State private var searchText = ""
|
||||
@FocusState private var searchFieldFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
// Search field
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
TextField(.searchForMedia, text: $searchText)
|
||||
.focused($searchFieldFocused)
|
||||
.onSubmit {
|
||||
performSearch()
|
||||
}
|
||||
|
||||
if !searchText.isEmpty {
|
||||
Button {
|
||||
searchText = ""
|
||||
searchModel.displayedResults = []
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.background(Color(.systemGray6))
|
||||
|
||||
if searchModel.isLoading {
|
||||
VStack {
|
||||
Spacer()
|
||||
ProgressView(.searching)
|
||||
.progressViewStyle(CircularProgressViewStyle())
|
||||
Spacer()
|
||||
}
|
||||
} else if searchModel.displayedResults.isEmpty && !searchText.isEmpty && searchModel.lastSearchedQuery == searchText {
|
||||
VStack {
|
||||
Spacer()
|
||||
Text(.noResultsFound)
|
||||
.foregroundColor(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
} else {
|
||||
// Results list
|
||||
List(searchModel.displayedResults, id: \.mediaUrl) { item in
|
||||
SearchResultRow(item: item) {
|
||||
model.onAdd(item.mediaUrl)
|
||||
}
|
||||
}
|
||||
.listStyle(PlainListStyle())
|
||||
}
|
||||
}
|
||||
.navigationTitle(.searchForMedia)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.onAppear {
|
||||
model.activeDetent = AddMediaView.ViewModel.Detent.expanded.value
|
||||
searchFieldFocused = true
|
||||
}
|
||||
}
|
||||
|
||||
private func performSearch() {
|
||||
guard !searchText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
|
||||
searchModel.performSearch(query: searchText)
|
||||
}
|
||||
}
|
||||
|
||||
struct SearchResultRow: View
|
||||
{
|
||||
let item: SearchResultItem
|
||||
let onTap: () -> Void
|
||||
|
||||
var body: some View {
|
||||
Button(action: onTap) {
|
||||
HStack(spacing: 12) {
|
||||
// Thumbnail
|
||||
AsyncImage(url: URL(string: item.thumbnailUrl)) { phase in
|
||||
switch phase {
|
||||
case .empty:
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 80, height: 60)
|
||||
.overlay {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
case .success(let image):
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(width: 80, height: 60)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
case .failure(_):
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 80, height: 60)
|
||||
.overlay {
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@unknown default:
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
.fill(Color(.systemGray5))
|
||||
.frame(width: 80, height: 60)
|
||||
}
|
||||
}
|
||||
|
||||
// Content
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(item.title)
|
||||
.font(.headline)
|
||||
.foregroundColor(.primary)
|
||||
.multilineTextAlignment(.leading)
|
||||
.lineLimit(2)
|
||||
|
||||
Text(item.author)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(item.type.capitalized)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 2)
|
||||
.background(Color(.systemGray6))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Image(systemName: "plus.circle.fill")
|
||||
.foregroundColor(.accentColor)
|
||||
.font(.title2)
|
||||
}
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
.buttonStyle(PlainButtonStyle())
|
||||
}
|
||||
}
|
||||
|
||||
extension SearchMediaView
|
||||
{
|
||||
// MARK: - Types
|
||||
|
||||
@Observable
|
||||
class SearchModel
|
||||
{
|
||||
var displayedResults: [SearchResultItem] = []
|
||||
var isLoading: Bool = false
|
||||
var lastSearchedQuery: String? = nil
|
||||
|
||||
func performSearch(query: String) {
|
||||
guard let api = Settings.fromDefaults().selectedServer?.api else { return }
|
||||
|
||||
isLoading = true
|
||||
lastSearchedQuery = query
|
||||
|
||||
Task {
|
||||
do {
|
||||
let fetchResult = try await api.search(query: query)
|
||||
if let results = fetchResult.results {
|
||||
await MainActor.run {
|
||||
self.displayedResults = results
|
||||
.map { item in
|
||||
// Convert relative thumbnail urls to absolute for loading by AsyncImage
|
||||
var copy = item
|
||||
copy.thumbnailUrl = api.baseURL.absoluteString
|
||||
.replacingOccurrences(of: "/api", with: "") + item.thumbnailUrl // xxx: ugh...
|
||||
return copy
|
||||
}
|
||||
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.displayedResults = []
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
ios/QueueCube/Views/ContentPlaceholderView.swift
Normal file
64
ios/QueueCube/Views/ContentPlaceholderView.swift
Normal file
@@ -0,0 +1,64 @@
|
||||
//
|
||||
// ContentPlaceholderView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentPlaceholderView<Label, Actions>: View
|
||||
where Label: View, Actions: View
|
||||
{
|
||||
let label: Label
|
||||
let actions: Actions
|
||||
|
||||
init(@ViewBuilder label: () -> Label, @ViewBuilder actions: () -> Actions = { EmptyView() }) {
|
||||
self.label = label()
|
||||
self.actions = actions()
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Spacer()
|
||||
|
||||
ContentUnavailableView {
|
||||
label
|
||||
.imageScale(.large)
|
||||
.tint(.secondary)
|
||||
} actions: { actions }
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
func contentPlaceholderView<Actions>(
|
||||
title: LocalizedStringKey,
|
||||
subtitle: (any StringProtocol)? = nil,
|
||||
systemImage: String, @ViewBuilder actions: () -> Actions = { EmptyView() })
|
||||
-> ContentPlaceholderView<AnyView, Actions>
|
||||
{
|
||||
ContentPlaceholderView(label: {
|
||||
AnyView(erasing: VStack(spacing: 16.0) {
|
||||
Image(systemName: systemImage)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
|
||||
.frame(width: 50.0, height: 50.0)
|
||||
.foregroundStyle(.secondary)
|
||||
.imageScale(.large)
|
||||
|
||||
|
||||
Text(title)
|
||||
.foregroundStyle(.tint)
|
||||
.bold()
|
||||
|
||||
if let subtitle {
|
||||
Text(subtitle)
|
||||
.foregroundStyle(.tint.opacity(0.5))
|
||||
}
|
||||
|
||||
Spacer()
|
||||
.frame(height: 14.0)
|
||||
})
|
||||
}, actions: actions)
|
||||
}
|
||||
199
ios/QueueCube/Views/ContentView.swift
Normal file
199
ios/QueueCube/Views/ContentView.swift
Normal file
@@ -0,0 +1,199 @@
|
||||
//
|
||||
// ContentView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 3/3/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View
|
||||
{
|
||||
@State var model = MainViewModel()
|
||||
@State private var websocketRestartTrigger = 0
|
||||
@Environment(\.scenePhase) private var scenePhase
|
||||
|
||||
var body: some View {
|
||||
MainView(model: $model)
|
||||
.task(id: websocketRestartTrigger) { await watchWebsocket() }
|
||||
.task { await refresh([.nowPlaying, .playlist, .favorites]) }
|
||||
.task { await watchForSettingsChanges() }
|
||||
.onChange(of: scenePhase) { oldPhase, newPhase in
|
||||
handleScenePhaseChange(from: oldPhase, to: newPhase)
|
||||
}
|
||||
.sheet(isPresented: $model.isNowPlayingSheetPresented) {
|
||||
NowPlayingView(model: model.nowPlayingViewModel)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.presentationDetents([ .height(320.0) ])
|
||||
}
|
||||
.sheet(isPresented: $model.isAddMediaSheetPresented) {
|
||||
AddMediaView(model: $model.addMediaViewModel)
|
||||
.presentationBackground(.regularMaterial)
|
||||
.presentationDetents(
|
||||
Set(AddMediaView.ViewModel.Detent.allCases.map { $0.value }),
|
||||
selection: $model.addMediaViewModel.activeDetent
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $model.isEditSheetPresented) {
|
||||
EditItemView(model: $model.editMediaViewModel)
|
||||
.presentationBackground(.regularMaterial)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
struct RefreshType: OptionSet
|
||||
{
|
||||
let rawValue: Int
|
||||
|
||||
static let nowPlaying = RefreshType(rawValue: 1 << 0)
|
||||
static let playlist = RefreshType(rawValue: 1 << 1)
|
||||
static let favorites = RefreshType(rawValue: 1 << 2)
|
||||
}
|
||||
}
|
||||
|
||||
extension ContentView
|
||||
{
|
||||
private func handleScenePhaseChange(from oldPhase: ScenePhase, to newPhase: ScenePhase) {
|
||||
// When app returns to active state from background, force reconnect and refresh
|
||||
if newPhase == .active {
|
||||
Task {
|
||||
// Force WebSocket reconnection
|
||||
websocketRestartTrigger += 1
|
||||
|
||||
// Give the WebSocket a moment to reconnect
|
||||
try? await Task.sleep(for: .milliseconds(100))
|
||||
|
||||
// Full UI refresh
|
||||
await refresh([.nowPlaying, .playlist, .favorites])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func refresh(_ what: RefreshType) async {
|
||||
await model.withModificationsViaAPI { api in
|
||||
if what.contains(.nowPlaying) {
|
||||
let nowPlaying = try await api.fetchNowPlayingInfo()
|
||||
model.nowPlayingViewModel.title = nowPlaying.playingItem?.title
|
||||
model.nowPlayingViewModel.subtitle = nowPlaying.playingItem?.filename
|
||||
|
||||
model.nowPlayingViewModel.isPlaying = !nowPlaying.isPaused
|
||||
model.nowPlayingViewModel.volume = Double(nowPlaying.volume) / 100.0
|
||||
model.playlistModel.isPlaying = !nowPlaying.isPaused
|
||||
model.favoritesModel.isPlaying = !nowPlaying.isPaused
|
||||
}
|
||||
|
||||
if what.contains(.playlist) {
|
||||
let playlist = try await api.fetchPlaylist()
|
||||
model.playlistModel.items = playlist.enumerated().map { (idx, mediaItem) in
|
||||
MediaListItem(
|
||||
id: String(mediaItem.id),
|
||||
title: mediaItem.displayTitle,
|
||||
filename: mediaItem.filename ?? "<null>",
|
||||
index: idx,
|
||||
isCurrent: mediaItem.current ?? false
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if what.contains(.favorites) {
|
||||
let favorites = try await api.fetchFavorites()
|
||||
let nowPlaying = try await api.fetchNowPlayingInfo()
|
||||
model.favoritesModel.items = favorites.map { mediaItem in
|
||||
MediaListItem(
|
||||
id: String(mediaItem.id),
|
||||
title: mediaItem.displayTitle,
|
||||
filename: mediaItem.filename ?? "<null>",
|
||||
isCurrent: nowPlaying.playingItem?.filename == mediaItem.filename
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func watchWebsocket() async {
|
||||
guard let api = model.selectedServer?.api else { return }
|
||||
|
||||
do {
|
||||
for await streamEvent in try await api.events() {
|
||||
switch streamEvent {
|
||||
case .event(let event):
|
||||
await clearConnectionErrorIfNecessary()
|
||||
await handle(event: event)
|
||||
case .error(let error):
|
||||
// Check if this is a backgrounding error (connection abort)
|
||||
let nsError = error as NSError
|
||||
let isBackgroundingError = nsError.code == 53
|
||||
|
||||
// Only show connection error to user if it's not a backgrounding error
|
||||
if !isBackgroundingError {
|
||||
model.connectionError = error
|
||||
}
|
||||
|
||||
// Always attempt reconnection after a delay
|
||||
Task { @MainActor in
|
||||
try await Task.sleep(for: .seconds(1.0))
|
||||
websocketRestartTrigger += 1
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
print("Events error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
private func handle(event: API.Event) async {
|
||||
switch event.type {
|
||||
case .volumeUpdate: fallthrough
|
||||
case .nowPlayingUpdate:
|
||||
await refresh(.nowPlaying)
|
||||
|
||||
case .playlistUpdate:
|
||||
await refresh(.playlist)
|
||||
|
||||
case .favoritesUpdate:
|
||||
await refresh(.favorites)
|
||||
|
||||
case .websocketReconnected: fallthrough
|
||||
case .metadataUpdate: fallthrough
|
||||
case .mpdUpdate:
|
||||
await refresh([.playlist, .nowPlaying, .favorites])
|
||||
|
||||
case .receivedWebsocketPong:
|
||||
// This means we're online.
|
||||
await clearConnectionErrorIfNecessary()
|
||||
}
|
||||
}
|
||||
|
||||
private func clearConnectionErrorIfNecessary() async {
|
||||
if model.connectionError != nil {
|
||||
model.connectionError = nil
|
||||
await refresh([.playlist, .nowPlaying, .favorites])
|
||||
}
|
||||
}
|
||||
|
||||
private func watchForSettingsChanges() async {
|
||||
let settingsChangedNotifications = NotificationCenter.default.notifications(named: .settingsChanged)
|
||||
.map({ _ in Optional.none })
|
||||
|
||||
for await _ in settingsChangedNotifications {
|
||||
let newSelectedServer = Settings.fromDefaults().selectedServer
|
||||
if newSelectedServer != model.selectedServer {
|
||||
model.selectedServer = newSelectedServer
|
||||
|
||||
// Reset view model to defaults
|
||||
await model.reset()
|
||||
|
||||
// Restart WebSocket connection for new server
|
||||
websocketRestartTrigger += 1
|
||||
|
||||
await refresh([.playlist, .nowPlaying, .favorites])
|
||||
}
|
||||
|
||||
// Always reset this
|
||||
model.serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
|
||||
}
|
||||
}
|
||||
}
|
||||
60
ios/QueueCube/Views/EditItemView.swift
Normal file
60
ios/QueueCube/Views/EditItemView.swift
Normal file
@@ -0,0 +1,60 @@
|
||||
//
|
||||
// EditItemView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/20/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
class EditItemViewModel
|
||||
{
|
||||
var mediaURL: String = ""
|
||||
var title: String = ""
|
||||
|
||||
var onDone: (EditItemViewModel) -> Void = { _ in }
|
||||
var onCancel: (EditItemViewModel) -> Void = { _ in }
|
||||
}
|
||||
|
||||
struct EditItemView: View
|
||||
{
|
||||
@Binding var model: EditItemViewModel
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section(.url) {
|
||||
TextField(.url, text: $model.mediaURL)
|
||||
.foregroundStyle(.secondary)
|
||||
.disabled(true) // editing URL not yet supported by server
|
||||
.contextMenu {
|
||||
Button(.copyURL) {
|
||||
UIPasteboard.general.string = model.mediaURL
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section(.title) {
|
||||
TextField(.title, text: $model.title)
|
||||
}
|
||||
}
|
||||
.navigationTitle(.editItem)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
Button(.cancel, role: .cancel) {
|
||||
model.onCancel(model)
|
||||
}
|
||||
}
|
||||
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button(.done, role: .destructive) {
|
||||
model.onDone(model)
|
||||
}
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
423
ios/QueueCube/Views/MainView.swift
Normal file
423
ios/QueueCube/Views/MainView.swift
Normal file
@@ -0,0 +1,423 @@
|
||||
//
|
||||
// MainView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
class MainViewModel
|
||||
{
|
||||
var selectedServer: Server? = Settings.fromDefaults().selectedServer
|
||||
|
||||
var connectionError: Error? = nil
|
||||
var selectedTab: Tab = .playlist
|
||||
|
||||
var isNowPlayingSheetPresented: Bool = false
|
||||
var isAddMediaSheetPresented: Bool = false
|
||||
var isEditSheetPresented: Bool = false
|
||||
|
||||
var playlistModel = MediaListViewModel(mode: .playlist)
|
||||
var favoritesModel = MediaListViewModel(mode: .favorites)
|
||||
var nowPlayingViewModel = NowPlayingViewModel()
|
||||
var addMediaViewModel = AddMediaView.ViewModel()
|
||||
var serverSelectionViewModel = ServerSelectionToolbarModifier.ViewModel()
|
||||
var editMediaViewModel = EditItemViewModel()
|
||||
|
||||
private var refreshingFromAPIDepth: UInt8 = 0
|
||||
private var isRefreshingFromAPI: Bool { refreshingFromAPIDepth > 0 }
|
||||
|
||||
enum Tab: String, CaseIterable
|
||||
{
|
||||
case playlist
|
||||
case favorites
|
||||
case settings
|
||||
}
|
||||
|
||||
init() {
|
||||
observePlaylistChanges()
|
||||
observeNowPlayingModel()
|
||||
configureViewModelCallbacks()
|
||||
}
|
||||
|
||||
func onAddButtonTapped() {
|
||||
isAddMediaSheetPresented = true
|
||||
}
|
||||
|
||||
func onNowPlayingMiniTapped() {
|
||||
isNowPlayingSheetPresented = true
|
||||
}
|
||||
|
||||
func reset() async {
|
||||
await withModificationsViaAPI { _ in
|
||||
playlistModel = MediaListViewModel(mode: .playlist)
|
||||
favoritesModel = MediaListViewModel(mode: .favorites)
|
||||
nowPlayingViewModel = NowPlayingViewModel()
|
||||
}
|
||||
|
||||
configureViewModelCallbacks()
|
||||
}
|
||||
|
||||
func configureViewModelCallbacks() {
|
||||
// Now Playing
|
||||
nowPlayingViewModel.onPlayPause = apiCallback { model, api in
|
||||
model.isPlaying ? try await api.pause() : try await api.play()
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onStop = apiCallback { model, api in
|
||||
try await api.stop()
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onNext = apiCallback { _, api in
|
||||
try await api.skip()
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onPrev = apiCallback { _, api in
|
||||
try await api.previous()
|
||||
}
|
||||
|
||||
nowPlayingViewModel.onSheetDismiss = { [weak self] _ in
|
||||
self?.isNowPlayingSheetPresented = false
|
||||
}
|
||||
|
||||
// Playlist
|
||||
playlistModel.onSeek = apiCallback { item, api in
|
||||
if let index = item.index {
|
||||
try await api.skip(index)
|
||||
}
|
||||
}
|
||||
|
||||
playlistModel.onFavorite = apiCallback { item, api in
|
||||
try await api.addFavorite(mediaURL: item.filename)
|
||||
}
|
||||
|
||||
// Favorites
|
||||
favoritesModel.onPlay = apiCallback { item, api in
|
||||
try await api.replace(mediaURL: item.filename)
|
||||
try await api.play()
|
||||
}
|
||||
|
||||
favoritesModel.onEdit = { [weak self] item in
|
||||
guard let self else { return }
|
||||
editMediaViewModel.mediaURL = item.filename
|
||||
editMediaViewModel.title = item.title
|
||||
isEditSheetPresented = true
|
||||
}
|
||||
|
||||
favoritesModel.onQueue = apiCallback { item, api in
|
||||
try await api.add(mediaURL: item.filename)
|
||||
}
|
||||
|
||||
// Edit
|
||||
editMediaViewModel.onCancel = { [weak self] _ in
|
||||
self?.isEditSheetPresented = false
|
||||
}
|
||||
|
||||
editMediaViewModel.onDone = apiCallback { [weak self] model, api in
|
||||
self?.isEditSheetPresented = false
|
||||
try await api.renameFavorite(mediaURL: model.mediaURL, title: model.title)
|
||||
}
|
||||
|
||||
// Add Media
|
||||
addMediaViewModel.onAdd = apiCallback { [weak self] mediaURL, api in
|
||||
guard let self else { return }
|
||||
|
||||
let strippedURL = mediaURL.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !strippedURL.isEmpty {
|
||||
addMediaViewModel.fieldContents = ""
|
||||
isAddMediaSheetPresented = false
|
||||
|
||||
switch selectedTab {
|
||||
case .playlist:
|
||||
try await api.add(mediaURL: strippedURL)
|
||||
case .favorites:
|
||||
try await api.addFavorite(mediaURL: strippedURL)
|
||||
case .settings:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
addMediaViewModel.onCancel = { [weak self] in
|
||||
self?.isAddMediaSheetPresented = false
|
||||
}
|
||||
}
|
||||
|
||||
func observeNowPlayingModel() {
|
||||
withObservationTracking {
|
||||
_ = nowPlayingViewModel.volume
|
||||
} onChange: { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let isRefreshing = isRefreshingFromAPI
|
||||
Task {
|
||||
if !isRefreshing {
|
||||
await self.withModificationsViaAPI { api in
|
||||
try await api.setVolume(self.nowPlayingViewModel.volume)
|
||||
}
|
||||
}
|
||||
|
||||
await MainActor.run { self.observeNowPlayingModel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func withModificationsViaAPI(_ modificationBlock: (API) async throws -> Void) async {
|
||||
guard let api = selectedServer?.api else { return }
|
||||
|
||||
refreshingFromAPIDepth += 1
|
||||
|
||||
do {
|
||||
try await modificationBlock(api)
|
||||
connectionError = nil
|
||||
} catch {
|
||||
print("Error refreshing content: \(error)")
|
||||
connectionError = error
|
||||
}
|
||||
|
||||
refreshingFromAPIDepth -= 1
|
||||
}
|
||||
|
||||
private func apiCallback<T>(_ f: @escaping (T, API) async throws -> Void) -> (T) -> Void {
|
||||
return { t in
|
||||
Task {
|
||||
await self.withModificationsViaAPI { try await f(t, $0) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func observePlaylistChanges() {
|
||||
withObservationTracking {
|
||||
_ = playlistModel.items
|
||||
_ = favoritesModel.items
|
||||
} onChange: { [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
let isRefreshing = isRefreshingFromAPI
|
||||
let oldPlaylist = playlistModel.items
|
||||
let oldFavorites = favoritesModel.items
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
|
||||
if !isRefreshing {
|
||||
// Notify server of removals
|
||||
let playlistDiff = playlistModel.items.difference(from: oldPlaylist) { $0.id == $1.id }
|
||||
await withModificationsViaAPI { api in
|
||||
for removal in playlistDiff.removals {
|
||||
switch removal {
|
||||
case .remove(let offset, _, _):
|
||||
try await api.delete(index: offset)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let favoritesDiff = favoritesModel.items.difference(from: oldFavorites) { $0.id == $1.id }
|
||||
await withModificationsViaAPI { api in
|
||||
for removal in favoritesDiff.removals {
|
||||
switch removal {
|
||||
case .remove(_, let favorite, _):
|
||||
try await api.deleteFavorite(mediaURL: favorite.filename)
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
observePlaylistChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainView: View
|
||||
{
|
||||
@Binding var model: MainViewModel
|
||||
@State var isSettingsVisible: Bool = false
|
||||
|
||||
init(model: Binding<MainViewModel>) {
|
||||
self._model = model
|
||||
|
||||
// If no servers are configured, make Settings the default tab.
|
||||
if !Settings.fromDefaults().isConfigured {
|
||||
model.wrappedValue.selectedTab = .settings
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
TabView(selection: $model.selectedTab) {
|
||||
Tab(.playlist, systemImage: "list.bullet", value: .playlist) {
|
||||
NavigationStack {
|
||||
MediaListView(model: $model.playlistModel)
|
||||
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
|
||||
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() }
|
||||
.displayingError(model.connectionError)
|
||||
.withAddButton { model.onAddButtonTapped() }
|
||||
.navigationTitle(.playlist)
|
||||
}
|
||||
}
|
||||
|
||||
Tab(.favorites, systemImage: "heart.fill", value: .favorites) {
|
||||
NavigationStack {
|
||||
MediaListView(model: $model.favoritesModel)
|
||||
.displayingServerSelectionToolbar(model: $model.serverSelectionViewModel)
|
||||
.displayingNowPlayingMiniPlayer(model: $model.nowPlayingViewModel) { model.onNowPlayingMiniTapped() }
|
||||
.displayingError(model.connectionError)
|
||||
.withAddButton { model.onAddButtonTapped() }
|
||||
.navigationTitle(.favorites)
|
||||
}
|
||||
}
|
||||
|
||||
Tab(.settings, systemImage: "gear", value: .settings) {
|
||||
SettingsView(onDone: {})
|
||||
}
|
||||
}
|
||||
.tabViewStyle(.sidebarAdaptable)
|
||||
}
|
||||
}
|
||||
|
||||
struct NowPlayingMiniPlayerModifier: ViewModifier
|
||||
{
|
||||
let onTap: () -> Void
|
||||
|
||||
@Binding var model: NowPlayingViewModel
|
||||
@State var nowPlayingHeight: CGFloat = 0.0
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
ZStack {
|
||||
content
|
||||
.safeAreaPadding(.bottom, nowPlayingHeight)
|
||||
|
||||
VStack {
|
||||
Spacer()
|
||||
|
||||
NowPlayingMiniView(model: $model, onTap: onTap)
|
||||
.padding()
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.frame(maxWidth: 800.0)
|
||||
.onGeometryChange(for: CGSize.self) { $0.size }
|
||||
action: { nowPlayingHeight = $0.height }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ServerSelectionToolbarModifier: ViewModifier
|
||||
{
|
||||
@Binding var model: ViewModel
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarLeading) {
|
||||
Menu {
|
||||
Section {
|
||||
ForEach(model.selectableServers) { server in
|
||||
Button {
|
||||
model.selectedServer = server
|
||||
} label: {
|
||||
Text(server.displayName)
|
||||
if model.selectedServer == server {
|
||||
Image(systemName: "checkmark")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if false
|
||||
// TODO
|
||||
Section {
|
||||
Button(.addServer) {
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
} label: {
|
||||
Label(model.selectedServer?.displayName ?? "Servers", systemImage: "chevron.down")
|
||||
.labelStyle(.titleAndIcon)
|
||||
}
|
||||
.buttonBorderShape(.capsule)
|
||||
.buttonStyle(.bordered)
|
||||
.menuStyle(.button)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
@Observable
|
||||
class ViewModel
|
||||
{
|
||||
var selectableServers: [Server] = Settings.fromDefaults().configuredServers
|
||||
var selectedServer: Server? = Settings.fromDefaults().selectedServer {
|
||||
didSet {
|
||||
Settings
|
||||
.fromDefaults()
|
||||
.selectedServer(selectedServer)
|
||||
.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct AddButtonToolbarModifier: ViewModifier
|
||||
{
|
||||
let onAdd: () -> Void
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
onAdd()
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ErrorDisplayModifier: ViewModifier
|
||||
{
|
||||
let error: Error?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.overlay {
|
||||
if error != nil {
|
||||
ZStack {
|
||||
Rectangle()
|
||||
.fill(.background)
|
||||
|
||||
contentPlaceholderView(
|
||||
title: .connectionError,
|
||||
subtitle: error?.localizedDescription,
|
||||
systemImage: "exclamationmark.triangle.fill"
|
||||
).tint(.label)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
func displayingServerSelectionToolbar(model: Binding<ServerSelectionToolbarModifier.ViewModel>) -> some View {
|
||||
modifier(ServerSelectionToolbarModifier(model: model))
|
||||
}
|
||||
|
||||
func displayingNowPlayingMiniPlayer(model: Binding<NowPlayingViewModel>, onTap: @escaping () -> Void) -> some View {
|
||||
modifier(NowPlayingMiniPlayerModifier(onTap: onTap, model: model))
|
||||
}
|
||||
|
||||
func withAddButton(onAdd: @escaping () -> Void) -> some View {
|
||||
modifier(AddButtonToolbarModifier(onAdd: onAdd))
|
||||
}
|
||||
|
||||
func displayingError(_ error: Error?) -> some View {
|
||||
modifier(ErrorDisplayModifier(error: error))
|
||||
}
|
||||
}
|
||||
|
||||
69
ios/QueueCube/Views/NowPlayingMiniView.swift
Normal file
69
ios/QueueCube/Views/NowPlayingMiniView.swift
Normal file
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// NowPlayingMiniView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/11/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct NowPlayingMiniView: View {
|
||||
@Binding var model: NowPlayingViewModel
|
||||
let onTap: () -> Void
|
||||
|
||||
@GestureState private var tapGestureState = false
|
||||
|
||||
private var nothingQueued: Bool {
|
||||
guard let title = model.title, let subtitle = model.subtitle else { return true }
|
||||
return title.isEmpty && subtitle.isEmpty
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
let playPauseImageName = model.isPlaying ? "pause.fill" : "play.fill"
|
||||
let tapGesture = DragGesture(minimumDistance: 0)
|
||||
.updating($tapGestureState) { _, state, _ in
|
||||
state = true
|
||||
}
|
||||
.onEnded { _ in
|
||||
onTap()
|
||||
}
|
||||
|
||||
HStack {
|
||||
VStack(alignment: .leading) {
|
||||
if let title = model.title, !title.isEmpty {
|
||||
Text(title)
|
||||
.font(.caption)
|
||||
.lineLimit(1)
|
||||
.bold()
|
||||
}
|
||||
|
||||
if let subtitle = model.subtitle, !subtitle.isEmpty {
|
||||
Text(subtitle)
|
||||
.lineLimit(1)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
|
||||
if nothingQueued {
|
||||
Text(.notPlaying)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Button(action: { model.onPlayPause(model) }) { Image(systemName: playPauseImageName) }
|
||||
.imageScale(.large)
|
||||
.padding(12.0)
|
||||
}
|
||||
.padding(EdgeInsets(top: 4.0, leading: 14.0, bottom: 4.0, trailing: 10.0))
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 12)
|
||||
.fill(tapGestureState ? .ultraThinMaterial : .bar)
|
||||
.stroke(.ultraThinMaterial, lineWidth: 1.0)
|
||||
)
|
||||
.shadow(color: .black.opacity(0.15), radius: 14.0, y: 2.0)
|
||||
.gesture(tapGesture)
|
||||
}
|
||||
}
|
||||
173
ios/QueueCube/Views/NowPlayingView.swift
Normal file
173
ios/QueueCube/Views/NowPlayingView.swift
Normal file
@@ -0,0 +1,173 @@
|
||||
//
|
||||
// NowPlayingView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 3/3/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
class NowPlayingViewModel
|
||||
{
|
||||
var onPlayPause: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onStop: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onNext: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onPrev: (NowPlayingViewModel) -> Void = { _ in }
|
||||
var onSheetDismiss: (NowPlayingViewModel) -> Void = { _ in }
|
||||
|
||||
var isPlaying: Bool = false
|
||||
var title: String? = ""
|
||||
var subtitle: String? = ""
|
||||
var volume: Double = 0.5
|
||||
|
||||
fileprivate var isSettingVolume: Bool = false
|
||||
fileprivate var settingVolume: Double = 0.0 {
|
||||
didSet { volume = settingVolume }
|
||||
}
|
||||
}
|
||||
|
||||
struct NowPlayingView: View
|
||||
{
|
||||
@State var model: NowPlayingViewModel
|
||||
private var nothingQueued: Bool { model.title == nil && model.subtitle == nil }
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
Spacer()
|
||||
.frame(height: 1.0)
|
||||
|
||||
VStack {
|
||||
if let title = model.title {
|
||||
Text(title)
|
||||
.font(.title2)
|
||||
.lineLimit(1)
|
||||
.bold()
|
||||
}
|
||||
|
||||
if let subtitle = model.subtitle {
|
||||
Text(subtitle)
|
||||
.font(.title3)
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
if nothingQueued {
|
||||
Text(.notPlaying)
|
||||
.font(.title2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 24.0)
|
||||
|
||||
VStack {
|
||||
HStack {
|
||||
ForEach(Buttons.allCases) { button in
|
||||
Spacer()
|
||||
|
||||
Button(action: button.action(model: model)) {
|
||||
Image(systemName: button.imageName(isPlaying: model.isPlaying))
|
||||
.resizable()
|
||||
.aspectRatio(1.0, contentMode: .fit)
|
||||
.scaleEffect(button.scale, anchor: .center)
|
||||
.tint(button.tintColor)
|
||||
}
|
||||
.disabled(nothingQueued)
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
.imageScale(.large)
|
||||
.frame(height: 34.0)
|
||||
.tint(.label)
|
||||
|
||||
Spacer()
|
||||
|
||||
Slider(
|
||||
value: model.isSettingVolume ? $model.settingVolume : $model.volume,
|
||||
in: 0.0...1.0,
|
||||
onEditingChanged: { editing in
|
||||
if model.isSettingVolume != editing {
|
||||
model.settingVolume = model.volume
|
||||
model.isSettingVolume = editing
|
||||
}
|
||||
}
|
||||
)
|
||||
.padding(.horizontal, 18.0)
|
||||
.padding(.bottom, -12.0) // intrinsic sizing bug workaround?
|
||||
}
|
||||
.padding(.vertical, 44.0)
|
||||
.padding(.horizontal, 12.0)
|
||||
.frame(maxWidth: .infinity)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 14.0)
|
||||
.fill(.ultraThinMaterial)
|
||||
.stroke(Color.label.opacity(0.08))
|
||||
)
|
||||
}
|
||||
|
||||
.padding(.horizontal, 15.0)
|
||||
.padding(.bottom, 10.0)
|
||||
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .topBarTrailing) {
|
||||
Button {
|
||||
model.onSheetDismiss(model)
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.tint(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
private enum Buttons: Int, CaseIterable, Identifiable {
|
||||
case backward
|
||||
case stop
|
||||
case playPause
|
||||
case forward
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var scale: Double {
|
||||
switch self {
|
||||
case .backward: 0.7
|
||||
case .forward: 0.7
|
||||
case .playPause: 1.0
|
||||
case .stop: 0.8
|
||||
}
|
||||
}
|
||||
|
||||
var tintColor: Color {
|
||||
switch self {
|
||||
case .backward: .label.mix(with: .gray, by: 0.5)
|
||||
case .forward: .label.mix(with: .gray, by: 0.5)
|
||||
case .playPause: .label
|
||||
case .stop: .label
|
||||
}
|
||||
}
|
||||
|
||||
func imageName(isPlaying: Bool) -> String {
|
||||
switch self {
|
||||
case .backward: "backward.fill"
|
||||
case .stop: "stop.fill"
|
||||
case .playPause: isPlaying ? "pause.fill" : "play.fill"
|
||||
case .forward: "forward.fill"
|
||||
}
|
||||
}
|
||||
|
||||
func action(model: NowPlayingViewModel) -> () -> Void {
|
||||
switch self {
|
||||
case .backward: { model.onPrev(model) }
|
||||
case .stop: { model.onStop(model) }
|
||||
case .playPause: { model.onPlayPause(model) }
|
||||
case .forward: { model.onNext(model) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
174
ios/QueueCube/Views/PlaylistView.swift
Normal file
174
ios/QueueCube/Views/PlaylistView.swift
Normal file
@@ -0,0 +1,174 @@
|
||||
//
|
||||
// PlaylistView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 3/3/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct MediaListItem: Identifiable
|
||||
{
|
||||
let _id: String
|
||||
let title: String
|
||||
let filename: String
|
||||
let index: Int?
|
||||
let isCurrent: Bool
|
||||
|
||||
var id: String {
|
||||
_id + filename // temporary: we get duplicate ids from the server sometimes...
|
||||
}
|
||||
|
||||
init(id: String, title: String, filename: String, index: Int? = nil, isCurrent: Bool = false) {
|
||||
self._id = id
|
||||
self.title = title
|
||||
self.filename = filename
|
||||
self.index = index
|
||||
self.isCurrent = isCurrent
|
||||
}
|
||||
}
|
||||
|
||||
enum MediaListMode {
|
||||
case playlist
|
||||
case favorites
|
||||
}
|
||||
|
||||
@Observable
|
||||
class MediaListViewModel
|
||||
{
|
||||
let mode: MediaListMode
|
||||
var isPlaying: Bool = false
|
||||
var items: [MediaListItem] = []
|
||||
|
||||
var onSeek: (MediaListItem) -> Void = { _ in }
|
||||
var onPlay: (MediaListItem) -> Void = { _ in }
|
||||
var onQueue: (MediaListItem) -> Void = { _ in }
|
||||
var onEdit: (MediaListItem) -> Void = { _ in }
|
||||
var onFavorite: (MediaListItem) -> Void = { _ in }
|
||||
|
||||
init(mode: MediaListMode) {
|
||||
self.mode = mode
|
||||
}
|
||||
}
|
||||
|
||||
struct MediaListView: View
|
||||
{
|
||||
@Binding var model: MediaListViewModel
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if model.items.isEmpty {
|
||||
let title: LocalizedStringKey = switch model.mode {
|
||||
case .playlist: .playlistEmpty
|
||||
case .favorites: .favoritesEmpty
|
||||
}
|
||||
|
||||
contentPlaceholderView(title: title, systemImage: "list.bullet")
|
||||
} else {
|
||||
List($model.items, editActions: .delete) { item in
|
||||
let item = item.wrappedValue
|
||||
let state = item.isCurrent ? (model.isPlaying ? MediaItemCell.State.playing : MediaItemCell.State.paused) : .queued
|
||||
|
||||
Button {
|
||||
switch model.mode {
|
||||
case .playlist:
|
||||
model.onSeek(item)
|
||||
case .favorites:
|
||||
model.onPlay(item)
|
||||
}
|
||||
} label: {
|
||||
MediaItemCell(
|
||||
title: item.title,
|
||||
subtitle: item.filename,
|
||||
state: state
|
||||
)
|
||||
}
|
||||
.listRowBackground((model.mode == .playlist && state != .queued) ? Color.accentColor.opacity(0.10) : nil)
|
||||
.contextMenu {
|
||||
Button(.copyTitle) {
|
||||
UIPasteboard.general.string = item.title
|
||||
}
|
||||
|
||||
Button(.copyURL) {
|
||||
if let url = URL(string: item.filename) {
|
||||
UIPasteboard.general.url = url
|
||||
} else {
|
||||
UIPasteboard.general.string = item.filename
|
||||
}
|
||||
}
|
||||
|
||||
if model.mode == .favorites {
|
||||
Button(.edit) {
|
||||
model.onEdit(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
.swipeActions(edge: .leading) {
|
||||
if model.mode == .favorites {
|
||||
Button {
|
||||
model.onQueue(item)
|
||||
} label: {
|
||||
Image(systemName: "plus.square.on.square")
|
||||
Text(.addToQueue)
|
||||
}
|
||||
.tint(.blue)
|
||||
} else if model.mode == .playlist {
|
||||
Button {
|
||||
model.onFavorite(item)
|
||||
} label: {
|
||||
Image(systemName: "star")
|
||||
Text(.favorite)
|
||||
}
|
||||
.tint(.yellow)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
struct MediaItemCell: View
|
||||
{
|
||||
let title: String
|
||||
let subtitle: String
|
||||
let state: State
|
||||
|
||||
var body: some View {
|
||||
let icon: String = switch state {
|
||||
case .queued: "play.fill"
|
||||
case .playing: "speaker.wave.3.fill"
|
||||
case .paused: "speaker.fill"
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
.tint(Color.primary)
|
||||
.frame(width: 15.0)
|
||||
.padding(.trailing, 10.0)
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(title)
|
||||
.tint(.primary)
|
||||
.lineLimit(1)
|
||||
|
||||
Text(subtitle)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(1)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
.padding([.top, .bottom], 4.0)
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum State {
|
||||
case queued
|
||||
case playing
|
||||
case paused
|
||||
}
|
||||
}
|
||||
|
||||
280
ios/QueueCube/Views/Settings View/AddServerView.swift
Normal file
280
ios/QueueCube/Views/Settings View/AddServerView.swift
Normal file
@@ -0,0 +1,280 @@
|
||||
//
|
||||
// AddServerView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import Network
|
||||
import SwiftUI
|
||||
|
||||
struct AddServerView: View
|
||||
{
|
||||
let onAddServer: (Server) -> Void
|
||||
@State var model = ViewModel()
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
// Manual Entry
|
||||
Section(.manual) {
|
||||
TextField(.serverURL, text: $model.serverURL)
|
||||
.autocapitalization(.none)
|
||||
.autocorrectionDisabled()
|
||||
.keyboardType(.URL)
|
||||
|
||||
switch model.validationState {
|
||||
case .empty:
|
||||
EmptyView()
|
||||
case .validating:
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text(.validating)
|
||||
}
|
||||
case .notValid:
|
||||
HStack {
|
||||
Image(systemName: "x.circle.fill")
|
||||
Text(.unableToConnect)
|
||||
}
|
||||
.foregroundStyle(.red)
|
||||
case .valid:
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
Text(.serverIsOnline)
|
||||
}
|
||||
.foregroundStyle(.green)
|
||||
|
||||
Button {
|
||||
// Force unwrap, since we validated it at this point.
|
||||
let server = Server(baseURL: URL(string: model.serverURL)!)
|
||||
onAddServer(server)
|
||||
} label: {
|
||||
HStack {
|
||||
Spacer()
|
||||
Text(.addServer)
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discovered
|
||||
Section(.discovered) {
|
||||
if model.discoveredServers.isEmpty {
|
||||
HStack {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
Text(.findingServers)
|
||||
}
|
||||
} else {
|
||||
List(model.discoveredServers) { (server: DiscoveredEndpoint) in
|
||||
Button {
|
||||
resolveEndpoint(server)
|
||||
} label: {
|
||||
HStack {
|
||||
Image(systemName: "network")
|
||||
Text("\(server.displayName)")
|
||||
.bold()
|
||||
|
||||
Spacer()
|
||||
if model.resolvingServers.contains(server) {
|
||||
ProgressView()
|
||||
.progressViewStyle(.circular)
|
||||
}
|
||||
}
|
||||
}
|
||||
.tint(.primary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.task {
|
||||
model.startDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
private func resolveEndpoint(_ endpoint: DiscoveredEndpoint) {
|
||||
Task {
|
||||
model.resolvingServers.insert(endpoint)
|
||||
|
||||
let server = try await endpoint.resolve()
|
||||
onAddServer(server)
|
||||
|
||||
model.resolvingServers.remove(endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
@Observable
|
||||
class ViewModel
|
||||
{
|
||||
var serverURL: String = ""
|
||||
var validationURL: String = ""
|
||||
var validationState: ValidationState = .empty
|
||||
|
||||
var discoveredServers: [DiscoveredEndpoint] = []
|
||||
var resolvingServers = Set<DiscoveredEndpoint>()
|
||||
|
||||
private let browser = NWBrowser(for: .bonjour(type: "_queuecube._tcp.", domain: "local."), using: .tcp)
|
||||
|
||||
private var validationTimer: Timer? = nil
|
||||
|
||||
init() {
|
||||
observeForValidation()
|
||||
}
|
||||
|
||||
public func startDiscovery() {
|
||||
browser.browseResultsChangedHandler = { [weak self] results, changes in
|
||||
guard let self else { return }
|
||||
self.discoveredServers = results.map { DiscoveredEndpoint(result: $0) }
|
||||
}
|
||||
|
||||
browser.stateUpdateHandler = { state in
|
||||
if case .failed(let error) = state {
|
||||
print("Discovery error: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
browser.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
|
||||
private func observeForValidation() {
|
||||
withObservationTracking {
|
||||
_ = serverURL
|
||||
} onChange: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
setNeedsValidation()
|
||||
observeForValidation()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setNeedsValidation() {
|
||||
self.validationURL = self.serverURL
|
||||
self.validationTimer?.invalidate()
|
||||
self.validationTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: false) { [weak self] _ in
|
||||
self?.validateSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func validateSettings() {
|
||||
guard !validationURL.isEmpty else {
|
||||
validationState = .empty
|
||||
return
|
||||
}
|
||||
|
||||
self.validationState = .validating
|
||||
|
||||
Task {
|
||||
do {
|
||||
let url = try URL(string: validationURL).try_unwrap()
|
||||
let api = API(baseURL: url)
|
||||
_ = try await api.fetchNowPlayingInfo()
|
||||
|
||||
self.validationState = .valid
|
||||
|
||||
if validationURL != serverURL {
|
||||
self.serverURL = self.validationURL
|
||||
}
|
||||
} catch {
|
||||
print("Validation failed: \(error)")
|
||||
|
||||
if !validationURL.hasSuffix("/api") {
|
||||
// Try adding /api and validating again.
|
||||
self.validationURL = serverURL.appending("/api")
|
||||
validateSettings()
|
||||
} else {
|
||||
self.validationState = .notValid
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum ValidationState
|
||||
{
|
||||
case empty
|
||||
case validating
|
||||
case notValid
|
||||
case valid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct DiscoveredEndpoint: Identifiable, Hashable
|
||||
{
|
||||
let endpoint: NWEndpoint
|
||||
let serviceName: String
|
||||
|
||||
var displayName: String {
|
||||
serviceName.queueCubeServiceName
|
||||
}
|
||||
|
||||
var id: String { serviceName }
|
||||
|
||||
init(result: NWBrowser.Result) {
|
||||
self.endpoint = result.endpoint
|
||||
|
||||
switch result.endpoint {
|
||||
case .service(name: let name, type: _, domain: _, interface: _):
|
||||
self.serviceName = name
|
||||
default:
|
||||
self.serviceName = "(Unknown)"
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func resolve() async throws -> Server {
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
let connection = NWConnection(to: endpoint, using: .tcp)
|
||||
connection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .preparing: break
|
||||
case .ready:
|
||||
// xxx: is this really the right way to do this? Maybe we should not try to turn this into a URL.
|
||||
if case .hostPort(host: let host, port: let port) = connection.currentPath?.remoteEndpoint {
|
||||
let address = switch host {
|
||||
case .name(let string, _): string
|
||||
case .ipv4(let iPv4Address): iPv4Address.debugDescription
|
||||
case .ipv6(let iPv6Address): iPv6Address.debugDescription
|
||||
default: "unknown"
|
||||
}
|
||||
|
||||
if let server = Server(serviceName: serviceName, host: address, port: port.rawValue) {
|
||||
continuation.resume(returning: server)
|
||||
} else {
|
||||
continuation.resume(throwing: Self.Error.urlError)
|
||||
}
|
||||
} else {
|
||||
continuation.resume(throwing: Self.Error.endpointIncorrect)
|
||||
}
|
||||
|
||||
connection.cancel()
|
||||
case .cancelled:
|
||||
// expected
|
||||
break
|
||||
case .failed(let error):
|
||||
continuation.resume(throwing: error)
|
||||
connection.cancel()
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
connection.start(queue: .global(qos: .userInitiated))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum Error: Swift.Error
|
||||
{
|
||||
case cancelledConnection
|
||||
case endpointIncorrect
|
||||
case urlError
|
||||
}
|
||||
}
|
||||
16
ios/QueueCube/Views/Settings View/GeneralSettingsView.swift
Normal file
16
ios/QueueCube/Views/Settings View/GeneralSettingsView.swift
Normal file
@@ -0,0 +1,16 @@
|
||||
//
|
||||
// GeneralSettingsView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct GeneralSettingsView: View
|
||||
{
|
||||
var body: some View {
|
||||
Text("Nothing here yet.")
|
||||
}
|
||||
}
|
||||
|
||||
120
ios/QueueCube/Views/Settings View/ServerListSettingsView.swift
Normal file
120
ios/QueueCube/Views/Settings View/ServerListSettingsView.swift
Normal file
@@ -0,0 +1,120 @@
|
||||
//
|
||||
// ServerListSettingsView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 6/10/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ServerListSettingsView: View
|
||||
{
|
||||
@State var model = ViewModel()
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
if model.configuredServers.isEmpty {
|
||||
contentPlaceholderView(title: .noServersConfigured, systemImage: "server.rack") {
|
||||
Button {
|
||||
model.isAddServerPresented = true
|
||||
} label: {
|
||||
Text(.addServer)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Form {
|
||||
List($model.configuredServers, editActions: [.delete]) { server in
|
||||
serverListItem(server.wrappedValue)
|
||||
.tag(server.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.navigationTitle(.servers)
|
||||
|
||||
.toolbar {
|
||||
Button {
|
||||
model.isAddServerPresented = true
|
||||
} label: {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
|
||||
.sheet(isPresented: $model.isAddServerPresented) {
|
||||
NavigationView {
|
||||
AddServerView(onAddServer: { model.onAddServer(server: $0) })
|
||||
.navigationTitle(.addServer)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .cancellationAction) {
|
||||
Button(.cancel) { model.isAddServerPresented = false }
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func serverListItem(_ server: Server) -> some View {
|
||||
HStack {
|
||||
Image(systemName: "hifispeaker.fill")
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
Text(server.displayName)
|
||||
.lineLimit(1)
|
||||
.bold()
|
||||
|
||||
Text(server.baseURL.absoluteString)
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
@Observable
|
||||
class ViewModel
|
||||
{
|
||||
var configuredServers: [Server]
|
||||
var isAddServerPresented = false
|
||||
var selectedItems: [Server.ID] = []
|
||||
|
||||
init() {
|
||||
self.configuredServers = Settings
|
||||
.fromDefaults()
|
||||
.configuredServers
|
||||
|
||||
observeForChanges()
|
||||
}
|
||||
|
||||
func observeForChanges() {
|
||||
withObservationTracking {
|
||||
_ = configuredServers
|
||||
} onChange: {
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
saveToSettings()
|
||||
observeForChanges()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func onAddServer(server: Server) {
|
||||
isAddServerPresented = false
|
||||
configuredServers = configuredServers + [ server ]
|
||||
saveToSettings()
|
||||
}
|
||||
|
||||
func saveToSettings() {
|
||||
Settings
|
||||
.fromDefaults()
|
||||
.configuredServers(configuredServers)
|
||||
.save()
|
||||
}
|
||||
}
|
||||
}
|
||||
61
ios/QueueCube/Views/Settings View/SettingsView.swift
Normal file
61
ios/QueueCube/Views/Settings View/SettingsView.swift
Normal file
@@ -0,0 +1,61 @@
|
||||
//
|
||||
// SettingsView.swift
|
||||
// QueueCube
|
||||
//
|
||||
// Created by James Magahern on 5/2/25.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct SettingsView: View
|
||||
{
|
||||
let onDone: () -> Void
|
||||
@State private var navigationPath: [SettingsPage]
|
||||
|
||||
init(onDone: @escaping () -> Void) {
|
||||
self.onDone = onDone
|
||||
self.navigationPath = if !Settings.fromDefaults().isConfigured {
|
||||
// Show server settings if not configured.
|
||||
[ .servers ]
|
||||
} else {
|
||||
[]
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
NavigationStack(path: $navigationPath) {
|
||||
List {
|
||||
NavigationLink(value: SettingsPage.general) {
|
||||
Image(systemName: "gear")
|
||||
Text(.general)
|
||||
}
|
||||
|
||||
NavigationLink(value: SettingsPage.servers) {
|
||||
Image(systemName: "server.rack")
|
||||
Text(.servers)
|
||||
}
|
||||
}
|
||||
.navigationDestination(for: SettingsPage.self, destination: { page in
|
||||
Group {
|
||||
switch page {
|
||||
case .general: GeneralSettingsView()
|
||||
case .servers: ServerListSettingsView()
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
})
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationTitle(.settings)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Types
|
||||
|
||||
enum SettingsPage: String, Identifiable
|
||||
{
|
||||
var id: String { rawValue }
|
||||
|
||||
case general
|
||||
case servers
|
||||
}
|
||||
}
|
||||
1
.gitignore → web/.gitignore
vendored
1
.gitignore → web/.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
build/
|
||||
result
|
||||
node_modules/
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -7,32 +7,36 @@ WORKDIR /app
|
||||
COPY backend/package*.json ./backend/
|
||||
COPY frontend/package*.json ./frontend/
|
||||
|
||||
# Install dependencies
|
||||
RUN cd backend && npm ci
|
||||
RUN cd frontend && npm ci
|
||||
|
||||
# Copy source files
|
||||
COPY . .
|
||||
|
||||
# Build frontend and backend
|
||||
RUN npm install
|
||||
RUN npm run build --workspaces
|
||||
|
||||
# Production stage
|
||||
FROM --platform=$TARGETPLATFORM debian:testing-20250203
|
||||
|
||||
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/*
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install only production dependencies
|
||||
COPY backend/package*.json ./
|
||||
COPY package-lock.json ./
|
||||
|
||||
RUN rm -rf node_modules/ # need to do a clean build
|
||||
RUN npm ci --production
|
||||
|
||||
# Copy built files
|
||||
COPY --from=builder /app/backend/build ./build
|
||||
COPY --from=builder /app/frontend/dist ./dist/frontend
|
||||
|
||||
# Copy entrypoint script
|
||||
COPY entrypoint.sh ./
|
||||
RUN chmod +x entrypoint.sh
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["node", "build/server.js"]
|
||||
CMD ["./entrypoint.sh"]
|
||||
674
web/LICENSE
Normal file
674
web/LICENSE
Normal file
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -21,6 +21,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/node-fetch": "^2.6.12",
|
||||
"bonjour-service": "^1.3.0",
|
||||
"classnames": "^2.5.1",
|
||||
"express": "^4.21.2",
|
||||
"express-ws": "^5.0.2",
|
||||
149
web/backend/src/FavoritesStore.ts
Normal file
149
web/backend/src/FavoritesStore.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
/*
|
||||
* FavoritesStore.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License,
|
||||
* or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import os from 'os';
|
||||
import { PlaylistItem } from './types';
|
||||
import { getLinkPreview } from 'link-preview-js';
|
||||
|
||||
export class FavoritesStore {
|
||||
onFavoritesChanged: (favorites: PlaylistItem[]) => void = () => {};
|
||||
|
||||
private storePath: string;
|
||||
private favorites: PlaylistItem[] = [];
|
||||
|
||||
constructor() {
|
||||
this.storePath = this.determineStorePath();
|
||||
this.loadFavorites();
|
||||
}
|
||||
|
||||
private determineStorePath(): string {
|
||||
const storeFilename = 'favorites.json';
|
||||
var storePath = path.join(os.tmpdir(), 'queuecube');
|
||||
|
||||
// Check for explicitly set path
|
||||
if (process.env.STORE_PATH) {
|
||||
storePath = path.resolve(process.env.STORE_PATH);
|
||||
}
|
||||
|
||||
// In production (in a container), use /app/data
|
||||
else if (process.env.NODE_ENV === 'production') {
|
||||
storePath = path.resolve('/app/data');
|
||||
}
|
||||
|
||||
fs.mkdir(storePath, { recursive: true }).catch(err => {
|
||||
console.error('Failed to create intermediate directory:', err);
|
||||
});
|
||||
|
||||
const fullPath = path.join(storePath, storeFilename);
|
||||
console.log("Favorites store path: " + fullPath);
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
private async loadFavorites() {
|
||||
try {
|
||||
// Ensure parent directory exists
|
||||
await fs.mkdir(path.dirname(this.storePath), { recursive: true });
|
||||
|
||||
const data = await fs.readFile(this.storePath, 'utf-8');
|
||||
this.favorites = JSON.parse(data);
|
||||
} catch (error) {
|
||||
// If file doesn't exist or is invalid, start with empty array
|
||||
this.favorites = [];
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
private async saveFavorites() {
|
||||
await fs.writeFile(this.storePath, JSON.stringify(this.favorites, null, 2));
|
||||
this.onFavoritesChanged(this.favorites);
|
||||
}
|
||||
|
||||
async getFavorites(): Promise<PlaylistItem[]> {
|
||||
return this.favorites;
|
||||
}
|
||||
|
||||
async addFavorite(filename: string): Promise<void> {
|
||||
// Check if the item already exists by filename
|
||||
const exists = this.favorites.some(f => f.filename === filename);
|
||||
if (!exists) {
|
||||
this.favorites.push({
|
||||
filename: filename,
|
||||
id: this.favorites.length // Generate new ID
|
||||
});
|
||||
await this.saveFavorites();
|
||||
|
||||
// Fetch metadata for the new favorite
|
||||
await this.fetchMetadata(filename);
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchMetadata(filename: string): Promise<void> {
|
||||
console.log("Fetching metadata for " + filename);
|
||||
const metadata = await getLinkPreview(filename);
|
||||
|
||||
const item: PlaylistItem = {
|
||||
filename: filename,
|
||||
id: this.favorites.length,
|
||||
metadata: {
|
||||
title: (metadata as any)?.title,
|
||||
description: (metadata as any)?.description,
|
||||
siteName: (metadata as any)?.siteName,
|
||||
},
|
||||
};
|
||||
|
||||
console.log("Metadata fetched for " + item.filename);
|
||||
console.log(item);
|
||||
|
||||
const index = this.favorites.findIndex(f => f.filename === filename);
|
||||
if (index !== -1) {
|
||||
this.favorites[index] = item;
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
|
||||
async removeFavorite(filename: string): Promise<void> {
|
||||
console.log("Removing favorite " + filename);
|
||||
this.favorites = this.favorites.filter(f => f.filename !== filename);
|
||||
await this.saveFavorites();
|
||||
}
|
||||
|
||||
async updateFavoriteTitle(filename: string, title: string): Promise<void> {
|
||||
console.log(`Updating title for favorite ${filename} to "${title}"`);
|
||||
const index = this.favorites.findIndex(f => f.filename === filename);
|
||||
if (index !== -1) {
|
||||
// Create metadata object if it doesn't exist
|
||||
if (!this.favorites[index].metadata) {
|
||||
this.favorites[index].metadata = {};
|
||||
}
|
||||
|
||||
// Update the title in metadata
|
||||
this.favorites[index].metadata!.title = title;
|
||||
|
||||
await this.saveFavorites();
|
||||
} else {
|
||||
throw new Error(`Favorite with filename ${filename} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async clearFavorites(): Promise<void> {
|
||||
this.favorites = [];
|
||||
await this.saveFavorites();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,21 @@
|
||||
/*
|
||||
* InvidiousAPI.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License,
|
||||
* or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import fetch from 'node-fetch';
|
||||
|
||||
interface InvidiousVideoThumbnail {
|
||||
@@ -30,7 +48,7 @@ export interface ThumbnailResponse {
|
||||
}
|
||||
|
||||
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`;
|
||||
|
||||
export const getInvidiousSearchURL = (query: string): string =>
|
||||
483
web/backend/src/MediaPlayer.ts
Normal file
483
web/backend/src/MediaPlayer.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
/*
|
||||
* MediaPlayer.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License,
|
||||
* or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { ChildProcess, spawn } from "child_process";
|
||||
import { Socket } from "net";
|
||||
import { WebSocket } from "ws";
|
||||
import { getLinkPreview } from "link-preview-js";
|
||||
import { PlaylistItem, LinkMetadata } from './types';
|
||||
import { FavoritesStore } from "./FavoritesStore";
|
||||
import { Bonjour } from "bonjour-service";
|
||||
import os from 'os';
|
||||
|
||||
interface PendingCommand {
|
||||
resolve: (value: any) => void;
|
||||
reject: (reason: any) => void;
|
||||
}
|
||||
|
||||
enum UserEvent {
|
||||
PlaylistUpdate = "playlist_update",
|
||||
NowPlayingUpdate = "now_playing_update",
|
||||
VolumeUpdate = "volume_update",
|
||||
FavoritesUpdate = "favorites_update",
|
||||
MetadataUpdate = "metadata_update",
|
||||
MPDUpdate = "mpd_update",
|
||||
}
|
||||
|
||||
export interface Features {
|
||||
video: boolean;
|
||||
screenshare: boolean;
|
||||
browserPlayback: boolean;
|
||||
}
|
||||
|
||||
export class MediaPlayer {
|
||||
private playerProcess: ChildProcess | null = null;
|
||||
private socket: Promise<Socket>;
|
||||
|
||||
private eventSubscribers: WebSocket[] = [];
|
||||
private favoritesStore: FavoritesStore;
|
||||
|
||||
private pendingCommands: Map<number, PendingCommand> = new Map();
|
||||
private requestId: number = 1;
|
||||
private dataBuffer: string = '';
|
||||
private metadata: Map<string, LinkMetadata> = new Map();
|
||||
private bonjourInstance: Bonjour | null = null;
|
||||
|
||||
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 socketPath = `/tmp/mpv-${socketFilename}`;
|
||||
const enableVideo = process.env.ENABLE_VIDEO || false;
|
||||
const logfilePath = `/tmp/mpv-logfile.txt`;
|
||||
|
||||
console.log("Starting player process (video: " + (enableVideo ? "enabled" : "disabled") + ")");
|
||||
this.playerProcess = spawn("mpv", [
|
||||
"--video=" + (enableVideo ? "auto" : "no"),
|
||||
"--fullscreen",
|
||||
"--no-terminal",
|
||||
"--idle=yes",
|
||||
"--input-ipc-server=" + socketPath,
|
||||
"--log-file=" + logfilePath,
|
||||
"--msg-level=all=v"
|
||||
]);
|
||||
|
||||
|
||||
let socketReady!: (s: Socket) => void;
|
||||
let socketPromise = new Promise<Socket>(resolve => {
|
||||
socketReady = resolve;
|
||||
});
|
||||
|
||||
this.playerProcess.on("spawn", () => {
|
||||
console.log(`Player process spawned, opening socket @ ${socketPath}`);
|
||||
setTimeout(() => {
|
||||
let socket = this.connectToSocket(socketPath);
|
||||
socketReady(socket);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
this.playerProcess.on("error", (error) => {
|
||||
console.error("Player process error:", error);
|
||||
console.log("Continuing without mpv player...");
|
||||
});
|
||||
|
||||
return socketPromise;
|
||||
}
|
||||
|
||||
public async getPlaylist(): Promise<PlaylistItem[]> {
|
||||
return this.writeCommand("get_property", ["playlist"])
|
||||
.then((response) => {
|
||||
// Enhance playlist items with metadata
|
||||
const playlist = response.data as PlaylistItem[];
|
||||
return playlist.map((item: PlaylistItem) => ({
|
||||
...item,
|
||||
metadata: this.metadata.get(item.filename) || {}
|
||||
}));
|
||||
});
|
||||
}
|
||||
|
||||
public async getNowPlaying(): Promise<PlaylistItem> {
|
||||
const playlist = await this.getPlaylist();
|
||||
const currentlyPlayingSong = playlist.find((item: PlaylistItem) => item.current);
|
||||
const fetchMediaTitle = async (): Promise<string | null> => {
|
||||
try {
|
||||
return (await this.writeCommand("get_property", ["media-title"])).data;
|
||||
} catch (err) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
if (currentlyPlayingSong !== undefined) {
|
||||
// Use media title if we don't have a title
|
||||
if (currentlyPlayingSong.title === undefined && currentlyPlayingSong.metadata?.title === undefined) {
|
||||
return {
|
||||
...currentlyPlayingSong,
|
||||
title: await fetchMediaTitle() || currentlyPlayingSong.filename
|
||||
};
|
||||
}
|
||||
|
||||
return currentlyPlayingSong;
|
||||
}
|
||||
|
||||
const mediaTitle = await fetchMediaTitle() || "";
|
||||
return {
|
||||
id: 0,
|
||||
filename: mediaTitle,
|
||||
title: mediaTitle
|
||||
};
|
||||
}
|
||||
|
||||
public async getCurrentFile(): Promise<string | null> {
|
||||
return this.writeCommand("get_property", ["stream-open-filename"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
}, (reject) => { return null; });
|
||||
}
|
||||
|
||||
public async getPauseState(): Promise<boolean> {
|
||||
return this.writeCommand("get_property", ["pause"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
public async getVolume(): Promise<number> {
|
||||
return this.writeCommand("get_property", ["volume"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
public async getTimePosition(): Promise<number | null> {
|
||||
return this.writeCommand("get_property", ["time-pos"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
}, (rejected) => { return null; });
|
||||
}
|
||||
|
||||
public async getDuration(): Promise<number | null> {
|
||||
return this.writeCommand("get_property", ["duration"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
}, (rejected) => { return null; });
|
||||
}
|
||||
|
||||
public async getSeekable(): Promise<boolean | null> {
|
||||
return this.writeCommand("get_property", ["seekable"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
}, (rejected) => { return null; });
|
||||
}
|
||||
|
||||
public async getIdle(): Promise<boolean> {
|
||||
return this.writeCommand("get_property", ["idle"])
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
});
|
||||
}
|
||||
|
||||
public async append(url: string) {
|
||||
await this.loadFile(url, "append-play");
|
||||
}
|
||||
|
||||
public async replace(url: string) {
|
||||
await this.loadFile(url, "replace");
|
||||
}
|
||||
|
||||
public async initiateScreenSharing(url: string) {
|
||||
console.log(`Initiating screen sharing with file: ${url}`);
|
||||
|
||||
this.metadata.set(url, {
|
||||
title: "Screen Sharing",
|
||||
description: "Screen Sharing",
|
||||
siteName: "Screen Sharing",
|
||||
});
|
||||
|
||||
// Special options for mpv to better handle screen sharing (AI recommended...)
|
||||
await this.loadFile(url, "replace", false, [
|
||||
"demuxer-lavf-o=fflags=+nobuffer+discardcorrupt", // Reduce buffering and discard corrupt frames
|
||||
"demuxer-lavf-o=analyzeduration=100000", // Reduce analyze duration
|
||||
"demuxer-lavf-o=probesize=1000000", // Reduce probe size
|
||||
"untimed=yes", // Ignore timing info
|
||||
"cache=no", // Disable cache
|
||||
"force-seekable=yes", // Force seekable
|
||||
"no-cache=yes", // Disable cache
|
||||
"demuxer-max-bytes=500K", // Limit demuxer buffer
|
||||
"demuxer-readahead-secs=0.1", // Reduce readahead
|
||||
"hr-seek=no", // Disable high-res seeking
|
||||
"video-sync=display-resample", // Better sync mode
|
||||
"video-latency-hacks=yes", // Enable latency hacks
|
||||
"audio-sync=yes", // Enable audio sync
|
||||
"audio-buffer=0.1", // Reduce audio buffer
|
||||
"audio-channels=stereo", // Force stereo audio
|
||||
"audio-samplerate=44100", // Match sample rate
|
||||
"audio-format=s16", // Use 16-bit audio
|
||||
]);
|
||||
|
||||
// Make sure it's playing
|
||||
setTimeout(() => this.play(), 100);
|
||||
}
|
||||
|
||||
public async play() {
|
||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", false]));
|
||||
}
|
||||
|
||||
public async pause() {
|
||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("set_property", ["pause", true]));
|
||||
}
|
||||
|
||||
public async stop() {
|
||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("stop", []));
|
||||
}
|
||||
|
||||
public async skip() {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-next", []));
|
||||
}
|
||||
|
||||
public async skipTo(index: number) {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-play-index", [index]));
|
||||
}
|
||||
|
||||
public async previous() {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-prev", []));
|
||||
}
|
||||
|
||||
public async deletePlaylistItem(index: number) {
|
||||
return this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("playlist-remove", [index]));
|
||||
}
|
||||
|
||||
public async setVolume(volume: number) {
|
||||
return this.modify(UserEvent.VolumeUpdate, () => this.writeCommand("set_property", ["volume", volume]));
|
||||
}
|
||||
|
||||
public async seek(time: number) {
|
||||
return this.modify(UserEvent.NowPlayingUpdate, () => this.writeCommand("seek", [time, "absolute"]));
|
||||
}
|
||||
|
||||
public subscribe(ws: WebSocket) {
|
||||
this.eventSubscribers.push(ws);
|
||||
}
|
||||
|
||||
public unsubscribe(ws: WebSocket) {
|
||||
this.eventSubscribers = this.eventSubscribers.filter(subscriber => subscriber !== ws);
|
||||
}
|
||||
|
||||
public async getFavorites(): Promise<PlaylistItem[]> {
|
||||
return this.favoritesStore.getFavorites();
|
||||
}
|
||||
|
||||
public async addFavorite(filename: string) {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.addFavorite(filename));
|
||||
}
|
||||
|
||||
public async removeFavorite(filename: string) {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.removeFavorite(filename));
|
||||
}
|
||||
|
||||
public async clearFavorites() {
|
||||
return this.modify(UserEvent.FavoritesUpdate, () => this.favoritesStore.clearFavorites());
|
||||
}
|
||||
|
||||
public async updateFavoriteTitle(filename: string, title: string) {
|
||||
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[] = []) {
|
||||
this.modify(UserEvent.PlaylistUpdate, () => this.writeCommand("loadfile", [url, mode, "-1", options.join(',')]));
|
||||
|
||||
if (fetchMetadata) {
|
||||
this.fetchMetadataAndNotify(url).catch(error => {
|
||||
console.warn(`Failed to fetch metadata for ${url}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private async modify<T>(event: UserEvent, func: () => Promise<T>): Promise<T> {
|
||||
return func()
|
||||
.then((result) => {
|
||||
// Notify all subscribers
|
||||
this.handleEvent(event, {});
|
||||
return result;
|
||||
}, (reject) => {
|
||||
console.log("Error modifying playlist: " + reject);
|
||||
return reject;
|
||||
});
|
||||
}
|
||||
|
||||
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) => {
|
||||
const id = this.requestId++;
|
||||
|
||||
const commandObject = JSON.stringify({
|
||||
command: [command, ...args],
|
||||
request_id: id
|
||||
});
|
||||
|
||||
try {
|
||||
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
|
||||
setTimeout(() => {
|
||||
if (this.pendingCommands.has(id)) {
|
||||
const pending = this.pendingCommands.get(id);
|
||||
if (pending) {
|
||||
pending.reject(new Error('Command timed out'));
|
||||
this.pendingCommands.delete(id);
|
||||
}
|
||||
}
|
||||
}, 5000);
|
||||
});
|
||||
}
|
||||
|
||||
private async fetchMetadataAndNotify(url: string) {
|
||||
try {
|
||||
console.log("Fetching metadata for " + url);
|
||||
const metadata = await getLinkPreview(url);
|
||||
this.metadata.set(url, {
|
||||
title: (metadata as any)?.title,
|
||||
description: (metadata as any)?.description,
|
||||
siteName: (metadata as any)?.siteName,
|
||||
});
|
||||
|
||||
console.log("Metadata fetched for " + url);
|
||||
console.log(this.metadata.get(url));
|
||||
|
||||
// Notify clients that metadata has been updated
|
||||
this.handleEvent(UserEvent.MetadataUpdate, {
|
||||
url,
|
||||
metadata: this.metadata.get(url)
|
||||
});
|
||||
} catch (error) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private connectToSocket(path: string): Socket {
|
||||
let socket = new Socket();
|
||||
socket.connect(path);
|
||||
socket.on("data", data => this.receiveData(data.toString()));
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
private handleEvent(event: string, data: any) {
|
||||
console.log("Event [" + event + "]: ", data);
|
||||
|
||||
// Notify all subscribers
|
||||
this.eventSubscribers.forEach(subscriber => {
|
||||
subscriber.send(JSON.stringify({ event, data }));
|
||||
});
|
||||
}
|
||||
|
||||
private receiveData(data: string) {
|
||||
this.dataBuffer += data;
|
||||
|
||||
const lines = this.dataBuffer.split('\n');
|
||||
|
||||
// Keep last incomplete line in the buffer
|
||||
this.dataBuffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.trim().length > 0) {
|
||||
try {
|
||||
const response = JSON.parse(line);
|
||||
if (response.request_id) {
|
||||
const pending = this.pendingCommands.get(response.request_id);
|
||||
if (pending) {
|
||||
if (response.error == "success") {
|
||||
pending.resolve(response);
|
||||
} else {
|
||||
pending.reject(response.error);
|
||||
}
|
||||
|
||||
this.pendingCommands.delete(response.request_id);
|
||||
}
|
||||
} else if (response.event) {
|
||||
this.handleEvent(UserEvent.MPDUpdate, response);
|
||||
} else {
|
||||
console.log(response);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error parsing JSON:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
342
web/backend/src/server.ts
Normal file
342
web/backend/src/server.ts
Normal file
@@ -0,0 +1,342 @@
|
||||
/*
|
||||
* server.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License,
|
||||
* or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import express from "express";
|
||||
import expressWs from "express-ws";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import { MediaPlayer } from "./MediaPlayer";
|
||||
import { searchInvidious, fetchThumbnail } from "./InvidiousAPI";
|
||||
import { PlaylistItem } from './types';
|
||||
import { PassThrough } from "stream";
|
||||
import { AddressInfo } from "net";
|
||||
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
expressWs(app);
|
||||
|
||||
const apiRouter = express.Router();
|
||||
const mediaPlayer = new MediaPlayer();
|
||||
|
||||
// Create a shared stream that both endpoints can access
|
||||
let activeScreenshareStream: PassThrough | null = null;
|
||||
let activeScreenshareMimeType: string | null = null;
|
||||
|
||||
const withErrorHandling = (func: (req: any, res: any) => Promise<any>) => {
|
||||
return async (req: any, res: any) => {
|
||||
try {
|
||||
await func(req, res);
|
||||
} catch (error: any) {
|
||||
console.log(`Error (${func.name}): ${error}`);
|
||||
res.status(500).send(JSON.stringify({ success: false, error: error.message }));
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
apiRouter.get("/playlist", withErrorHandling(async (req, res) => {
|
||||
const playlist = await mediaPlayer.getPlaylist();
|
||||
res.send(playlist);
|
||||
}));
|
||||
|
||||
apiRouter.post("/playlist", withErrorHandling(async (req, res) => {
|
||||
const { url } = req.body as { url: string };
|
||||
await mediaPlayer.append(url);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/playlist/:index", withErrorHandling(async (req, res) => {
|
||||
const { index } = req.params as { index: string };
|
||||
await mediaPlayer.deletePlaylistItem(parseInt(index));
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/playlist/replace", withErrorHandling(async (req, res) => {
|
||||
const { url } = req.body as { url: string };
|
||||
await mediaPlayer.replace(url);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/play", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.play();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/pause", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.pause();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/stop", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.stop();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/skip", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.skip();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/skip/:index", withErrorHandling(async (req, res) => {
|
||||
const { index } = req.params as { index: string };
|
||||
await mediaPlayer.skipTo(parseInt(index));
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/previous", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.previous();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.get("/nowplaying", withErrorHandling(async (req, res) => {
|
||||
const playingItem = await mediaPlayer.getNowPlaying();
|
||||
const currentFile = await mediaPlayer.getCurrentFile();
|
||||
const pauseState = await mediaPlayer.getPauseState();
|
||||
const volume = await mediaPlayer.getVolume();
|
||||
const idle = await mediaPlayer.getIdle();
|
||||
const timePosition = await mediaPlayer.getTimePosition();
|
||||
const duration = await mediaPlayer.getDuration();
|
||||
const seekable = await mediaPlayer.getSeekable();
|
||||
|
||||
res.send(JSON.stringify({
|
||||
success: true,
|
||||
playingItem: playingItem,
|
||||
isPaused: pauseState,
|
||||
volume: volume,
|
||||
isIdle: idle,
|
||||
currentFile: currentFile,
|
||||
timePosition: timePosition,
|
||||
duration: duration,
|
||||
seekable: seekable
|
||||
}));
|
||||
}));
|
||||
|
||||
apiRouter.post("/volume", withErrorHandling(async (req, res) => {
|
||||
const { volume } = req.body as { volume: number };
|
||||
await mediaPlayer.setVolume(volume);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.post("/player/seek", withErrorHandling(async (req, res) => {
|
||||
const { time } = req.body as { time: number };
|
||||
await mediaPlayer.seek(time);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.ws("/events", (ws, req) => {
|
||||
console.log("Events client connected");
|
||||
mediaPlayer.subscribe(ws);
|
||||
|
||||
ws.on("close", () => {
|
||||
console.log("Events client disconnected");
|
||||
mediaPlayer.unsubscribe(ws);
|
||||
});
|
||||
});
|
||||
|
||||
// This is effectively a "private" endpoint that only the MPV instance accesses. We're
|
||||
// using the fact that QueueCube/MPV is based all around streaming URLs, so the active
|
||||
// screenshare stream manifests as just another URL to play.
|
||||
apiRouter.get("/screenshareStream", withErrorHandling(async (req, res) => {
|
||||
res.setHeader("Content-Type", activeScreenshareMimeType || "video/mp4");
|
||||
res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
|
||||
res.setHeader("Pragma", "no-cache");
|
||||
res.setHeader("Expires", "0");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
res.setHeader("Transfer-Encoding", "chunked");
|
||||
|
||||
if (!activeScreenshareStream) {
|
||||
res.status(503).send("No active screen sharing session");
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle client disconnection
|
||||
req.on('close', () => {
|
||||
console.log("Screenshare viewer disconnected");
|
||||
});
|
||||
|
||||
// Configure stream for low latency
|
||||
activeScreenshareStream.setMaxListeners(0);
|
||||
|
||||
// Pipe with immediate flush
|
||||
activeScreenshareStream.pipe(res, { end: false });
|
||||
}));
|
||||
|
||||
apiRouter.ws("/screenshare", (ws, req) => {
|
||||
const mimeType = req.query.mimeType as string;
|
||||
console.log("Screen sharing client connected with mimeType: " + mimeType);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
let firstChunk = false;
|
||||
|
||||
// Configure WebSocket for low latency
|
||||
ws.setMaxListeners(0);
|
||||
ws.binaryType = "arraybuffer";
|
||||
|
||||
ws.on('message', (data: any) => {
|
||||
const buffer = data instanceof Buffer ? data : Buffer.from(data);
|
||||
|
||||
if (!firstChunk) {
|
||||
firstChunk = true;
|
||||
|
||||
const port = (server.address() as AddressInfo).port;
|
||||
const url = `http://localhost:${port}/api/screenshareStream`;
|
||||
console.log(`Starting screen share stream at ${url}`);
|
||||
|
||||
// Create new shared stream with immediate flush
|
||||
activeScreenshareStream = new PassThrough({
|
||||
highWaterMark: 1024 * 1024, // 1MB buffer
|
||||
allowHalfOpen: false
|
||||
});
|
||||
|
||||
activeScreenshareStream.write(buffer);
|
||||
mediaPlayer.initiateScreenSharing(url);
|
||||
} else if (activeScreenshareStream) {
|
||||
// Write with immediate flush
|
||||
activeScreenshareStream.write(buffer, () => {
|
||||
activeScreenshareStream?.cork();
|
||||
activeScreenshareStream?.uncork();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log("Screen sharing client disconnected");
|
||||
if (activeScreenshareStream) {
|
||||
activeScreenshareStream.end();
|
||||
activeScreenshareStream = null;
|
||||
}
|
||||
mediaPlayer.stop();
|
||||
});
|
||||
});
|
||||
|
||||
apiRouter.get("/search", withErrorHandling(async (req, res) => {
|
||||
const query = req.query.q as string;
|
||||
if (!query) {
|
||||
res.status(400)
|
||||
.send(JSON.stringify({ success: false, error: "Query parameter 'q' is required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await searchInvidious(query);
|
||||
res.send(JSON.stringify({ success: true, results }));
|
||||
}));
|
||||
|
||||
apiRouter.get("/thumbnail", withErrorHandling(async (req, res) => {
|
||||
const thumbnailUrl = req.query.url as string;
|
||||
if (!thumbnailUrl) {
|
||||
res.status(400)
|
||||
.send(JSON.stringify({ success: false, error: "URL parameter is required" }));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { data, contentType } = await fetchThumbnail(thumbnailUrl);
|
||||
res.set('Content-Type', contentType);
|
||||
data.pipe(res);
|
||||
} catch (error) {
|
||||
console.error('Failed to proxy thumbnail:', error);
|
||||
res.status(500)
|
||||
.send(JSON.stringify({ success: false, error: 'Failed to fetch thumbnail' }));
|
||||
}
|
||||
}));
|
||||
|
||||
apiRouter.get("/favorites", withErrorHandling(async (req, res) => {
|
||||
const favorites = await mediaPlayer.getFavorites();
|
||||
res.send(JSON.stringify(favorites));
|
||||
}));
|
||||
|
||||
apiRouter.post("/favorites", withErrorHandling(async (req, res) => {
|
||||
const { filename } = req.body as { filename: string };
|
||||
console.log("Adding favorite: " + filename);
|
||||
await mediaPlayer.addFavorite(filename);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/favorites/:filename", withErrorHandling(async (req, res) => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
await mediaPlayer.removeFavorite(filename);
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.delete("/favorites", withErrorHandling(async (req, res) => {
|
||||
await mediaPlayer.clearFavorites();
|
||||
res.send(JSON.stringify({ success: true }));
|
||||
}));
|
||||
|
||||
apiRouter.put("/favorites/:filename/title", withErrorHandling(async (req, res) => {
|
||||
const { filename } = req.params as { filename: string };
|
||||
const { title } = req.body as { title: string };
|
||||
|
||||
if (!title) {
|
||||
res.status(400).send(JSON.stringify({
|
||||
success: false,
|
||||
error: "Title is required"
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
await mediaPlayer.updateFavoriteTitle(filename, title);
|
||||
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)
|
||||
app.use(express.static(path.join(__dirname, "../dist/frontend")));
|
||||
|
||||
// Mount API routes under /api
|
||||
app.use("/api", apiRouter);
|
||||
|
||||
// Serve React app for all other routes (client-side routing)
|
||||
app.get("*", (req, res) => {
|
||||
res.sendFile(path.join(__dirname, "../dist/frontend/index.html"));
|
||||
});
|
||||
|
||||
const port = process.env.PORT || 3000;
|
||||
const server = app.listen(port, () => {
|
||||
console.log(`Server is running on port ${port}`);
|
||||
|
||||
// Start zeroconf service advertisement
|
||||
mediaPlayer.startZeroconfService(Number(port));
|
||||
});
|
||||
|
||||
// Add graceful shutdown handling
|
||||
const shutdown = async () => {
|
||||
console.log('Received shutdown signal. Closing server...');
|
||||
|
||||
// Stop zeroconf service
|
||||
mediaPlayer.stopZeroconfService();
|
||||
|
||||
server.close(() => {
|
||||
console.log('Server closed');
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
// Force termination after some timeout (10sec)
|
||||
setTimeout(() => {
|
||||
console.log('Forcing server shutdown');
|
||||
process.exit(1);
|
||||
}, 10000);
|
||||
};
|
||||
|
||||
// Handle various shutdown signals
|
||||
process.on('SIGTERM', shutdown);
|
||||
process.on('SIGINT', shutdown);
|
||||
32
web/backend/src/types.ts
Normal file
32
web/backend/src/types.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/*
|
||||
* types.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License,
|
||||
* or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export interface LinkMetadata {
|
||||
title?: string;
|
||||
description?: string;
|
||||
siteName?: string;
|
||||
}
|
||||
|
||||
export interface PlaylistItem {
|
||||
id: number;
|
||||
filename: string;
|
||||
title?: string;
|
||||
playing?: boolean;
|
||||
current?: boolean;
|
||||
metadata?: LinkMetadata;
|
||||
}
|
||||
46
web/entrypoint.sh
Executable file
46
web/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
|
||||
61
web/flake.lock
generated
Normal file
61
web/flake.lock
generated
Normal file
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"nodes": {
|
||||
"flake-utils": {
|
||||
"inputs": {
|
||||
"systems": "systems"
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1731533236,
|
||||
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "numtide",
|
||||
"repo": "flake-utils",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"nixpkgs": {
|
||||
"locked": {
|
||||
"lastModified": 1740828860,
|
||||
"narHash": "sha256-cjbHI+zUzK5CPsQZqMhE3npTyYFt9tJ3+ohcfaOF/WM=",
|
||||
"owner": "NixOS",
|
||||
"repo": "nixpkgs",
|
||||
"rev": "303bd8071377433a2d8f76e684ec773d70c5b642",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "NixOS",
|
||||
"ref": "nixos-unstable",
|
||||
"repo": "nixpkgs",
|
||||
"type": "github"
|
||||
}
|
||||
},
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
"locked": {
|
||||
"lastModified": 1681028828,
|
||||
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "nix-systems",
|
||||
"repo": "default",
|
||||
"type": "github"
|
||||
}
|
||||
}
|
||||
},
|
||||
"root": "root",
|
||||
"version": 7
|
||||
}
|
||||
196
web/flake.nix
Normal file
196
web/flake.nix
Normal file
@@ -0,0 +1,196 @@
|
||||
{
|
||||
description = "NodeJS application with mpv, yt-dlp, and pulseaudio dependencies";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils }:
|
||||
let
|
||||
# Define the NixOS module for the systemd service
|
||||
nixosModule = { config, lib, pkgs, ... }:
|
||||
let
|
||||
cfg = config.services.queuecube;
|
||||
in {
|
||||
options.services.queuecube = {
|
||||
enable = lib.mkEnableOption "QueueCube media player service";
|
||||
|
||||
port = lib.mkOption {
|
||||
type = lib.types.port;
|
||||
default = 3000;
|
||||
description = "Port on which QueueCube will listen";
|
||||
};
|
||||
|
||||
enable_video = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable video playback";
|
||||
};
|
||||
|
||||
store_path = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "/var/tmp/queuecube";
|
||||
description = "Path to the store for QueueCube";
|
||||
};
|
||||
|
||||
invidious = lib.mkOption {
|
||||
type = lib.types.submodule {
|
||||
options = {
|
||||
enable = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
description = "Enable Invidious";
|
||||
};
|
||||
|
||||
url = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
default = "http://invidious.nor";
|
||||
description = "URL of the Invidious instance to use";
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
default = {
|
||||
enable = false;
|
||||
url = "http://invidious.nor";
|
||||
};
|
||||
};
|
||||
|
||||
user = lib.mkOption {
|
||||
type = lib.types.str;
|
||||
description = "User account under which QueueCube runs (required)";
|
||||
};
|
||||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
users.users.${cfg.user} = {
|
||||
packages = [ self.packages.${pkgs.system}.queuecube ];
|
||||
};
|
||||
|
||||
systemd.user.services.queuecube = {
|
||||
description = "QueueCube media player service";
|
||||
wantedBy = [ "default.target" ];
|
||||
after = [ "pipewire.service" "pipewire-pulse.service" ];
|
||||
|
||||
serviceConfig = {
|
||||
ExecStart = "${self.packages.${pkgs.system}.queuecube}/bin/queuecube";
|
||||
Restart = "on-failure";
|
||||
RestartSec = 5;
|
||||
|
||||
# Allow access to X11 for mpv
|
||||
Environment = [ "DISPLAY=:0" ];
|
||||
};
|
||||
|
||||
environment = {
|
||||
PORT = toString cfg.port;
|
||||
ENABLE_VIDEO = if cfg.enable_video then "1" else "0";
|
||||
USE_INVIDIOUS = if cfg.invidious.enable then "1" else "0";
|
||||
INVIDIOUS_BASE_URL = cfg.invidious.url;
|
||||
STORE_PATH = cfg.store_path;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
in
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
pkgs = nixpkgs.legacyPackages.${system};
|
||||
|
||||
# Define the package using buildNpmPackage
|
||||
queuecube = pkgs.buildNpmPackage {
|
||||
pname = "queuecube";
|
||||
version = "0.1.0";
|
||||
|
||||
src = ./.;
|
||||
|
||||
# Skip the standard buildPhase and provide our own
|
||||
dontNpmBuild = true;
|
||||
buildPhase = ''
|
||||
# First install all dependencies
|
||||
npm install
|
||||
|
||||
# Then run the build with workspaces flag
|
||||
npm run build --workspaces
|
||||
'';
|
||||
|
||||
# Runtime dependencies
|
||||
buildInputs = with pkgs; [
|
||||
mpv
|
||||
yt-dlp
|
||||
pulseaudio
|
||||
];
|
||||
|
||||
# Create a wrapper script to ensure runtime deps are available
|
||||
postInstall = ''
|
||||
# Create the necessary directories
|
||||
mkdir -p $out/lib/node_modules/queuecube
|
||||
|
||||
# Copy the entire project with built files
|
||||
cp -r . $out/lib/node_modules/queuecube
|
||||
|
||||
# Install the frontend build to the backend dist directory
|
||||
mkdir -p $out/lib/node_modules/queuecube/backend/dist/
|
||||
cp -r frontend/dist $out/lib/node_modules/queuecube/backend/dist/frontend
|
||||
|
||||
# Create bin directory if it doesn't exist
|
||||
mkdir -p $out/bin
|
||||
|
||||
# Create executable script
|
||||
cat > $out/bin/queuecube <<EOF
|
||||
#!/bin/sh
|
||||
exec ${pkgs.nodejs}/bin/node $out/lib/node_modules/queuecube/backend/build/server.js
|
||||
EOF
|
||||
|
||||
# Make it executable
|
||||
chmod +x $out/bin/queuecube
|
||||
|
||||
# Wrap the program to include runtime deps in PATH
|
||||
wrapProgram $out/bin/queuecube \
|
||||
--prefix PATH : ${pkgs.lib.makeBinPath [
|
||||
pkgs.mpv
|
||||
pkgs.yt-dlp
|
||||
pkgs.pulseaudio
|
||||
]}
|
||||
'';
|
||||
|
||||
# Let buildNpmPackage handle npm package hash
|
||||
npmDepsHash = "sha256-kwbWqNqji0EcBeRuc/sqQUuGQkE+P8puLTfpAyRRzgY=";
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "NodeJS application with media playback capabilities";
|
||||
platforms = platforms.linux;
|
||||
};
|
||||
};
|
||||
|
||||
in {
|
||||
packages = {
|
||||
default = queuecube;
|
||||
queuecube = queuecube;
|
||||
};
|
||||
|
||||
apps.default = {
|
||||
type = "app";
|
||||
program = "${queuecube}/bin/queuecube";
|
||||
};
|
||||
|
||||
# Development environment
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = with pkgs; [
|
||||
nodejs_20
|
||||
nodePackages.npm
|
||||
mpv
|
||||
yt-dlp
|
||||
pulseaudio
|
||||
];
|
||||
};
|
||||
|
||||
# Add a basic check to verify the package builds
|
||||
checks.queuecube = queuecube;
|
||||
}
|
||||
) // {
|
||||
# Export the NixOS module
|
||||
nixosModules.default = nixosModule;
|
||||
nixosModules.queuecube = nixosModule;
|
||||
};
|
||||
}
|
||||
@@ -24,7 +24,7 @@
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.18",
|
||||
"globals": "^15.14.0",
|
||||
"typescript": "~5.7.2",
|
||||
"typescript": "^5.8.3",
|
||||
"typescript-eslint": "^8.22.0",
|
||||
"vite": "^6.1.0"
|
||||
}
|
||||
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 7.9 KiB |
@@ -5,6 +5,15 @@ export interface NowPlayingResponse {
|
||||
volume: number;
|
||||
isIdle: boolean;
|
||||
currentFile: string;
|
||||
timePosition?: number;
|
||||
duration?: number;
|
||||
seekable?: boolean;
|
||||
}
|
||||
|
||||
export interface Features {
|
||||
video: boolean;
|
||||
screenshare: boolean;
|
||||
browserPlayback: boolean;
|
||||
}
|
||||
|
||||
export interface Metadata {
|
||||
@@ -53,9 +62,15 @@ export enum ServerEvent {
|
||||
FavoritesUpdate = "favorites_update",
|
||||
MetadataUpdate = "metadata_update",
|
||||
MPDUpdate = "mpd_update",
|
||||
ScreenShare = "screen_share",
|
||||
}
|
||||
|
||||
export const API = {
|
||||
async getFeatures(): Promise<Features> {
|
||||
const response = await fetch('/api/features');
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async getPlaylist(): Promise<PlaylistItem[]> {
|
||||
const response = await fetch('/api/playlist');
|
||||
return response.json();
|
||||
@@ -90,6 +105,10 @@ export const API = {
|
||||
async play(): Promise<void> {
|
||||
await fetch('/api/play', { method: 'POST' });
|
||||
},
|
||||
|
||||
async stop(): Promise<void> {
|
||||
await fetch('/api/stop', { method: 'POST' });
|
||||
},
|
||||
|
||||
async pause(): Promise<void> {
|
||||
await fetch('/api/pause', { method: 'POST' });
|
||||
@@ -121,6 +140,16 @@ export const API = {
|
||||
body: JSON.stringify({ volume }),
|
||||
});
|
||||
},
|
||||
|
||||
async seek(time: number): Promise<void> {
|
||||
await fetch('/api/player/seek', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ time }),
|
||||
});
|
||||
},
|
||||
|
||||
async search(query: string): Promise<SearchResponse> {
|
||||
const response = await fetch(`/api/search?q=${encodeURIComponent(query)}`);
|
||||
@@ -142,31 +171,37 @@ export const API = {
|
||||
return response.json();
|
||||
},
|
||||
|
||||
async addToFavorites(url: string): Promise<void> {
|
||||
// Maybe a little weird to make an empty PlaylistItem here, but we do want to support adding
|
||||
// known PlaylistItems to favorites in the future.
|
||||
const playlistItem: PlaylistItem = {
|
||||
filename: url,
|
||||
title: null,
|
||||
id: 0,
|
||||
playing: null,
|
||||
metadata: undefined,
|
||||
};
|
||||
|
||||
async addToFavorites(filename: string): Promise<void> {
|
||||
await fetch('/api/favorites', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(playlistItem),
|
||||
body: JSON.stringify({ filename }),
|
||||
});
|
||||
},
|
||||
|
||||
async removeFromFavorites(id: number): Promise<void> {
|
||||
await fetch(`/api/favorites/${id}`, { method: 'DELETE' });
|
||||
async removeFromFavorites(filename: string): Promise<void> {
|
||||
await fetch(`/api/favorites/${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async clearFavorites(): Promise<void> {
|
||||
await fetch('/api/favorites', { method: 'DELETE' });
|
||||
},
|
||||
|
||||
async updateFavoriteTitle(filename: string, title: string): Promise<void> {
|
||||
await fetch(`/api/favorites/${encodeURIComponent(filename)}/title`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ title }),
|
||||
});
|
||||
},
|
||||
|
||||
startScreenShare(mimeType: string): WebSocket {
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss' : 'ws';
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/api/screenshare?mimeType=${mimeType}`);
|
||||
return ws;
|
||||
}
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import React, { useState, KeyboardEvent, ChangeEvent } from 'react';
|
||||
import { FaSearch } from 'react-icons/fa';
|
||||
import InvidiousSearchModal from './InvidiousSearchModal';
|
||||
import { USE_INVIDIOUS } from '../config';
|
||||
|
||||
interface AddSongPanelProps {
|
||||
onAddURL: (url: string) => void;
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import React, { useState, useEffect, useCallback, ReactNode } from 'react';
|
||||
import SongTable from './SongTable';
|
||||
import NowPlaying from './NowPlaying';
|
||||
import AddSongPanel from './AddSongPanel';
|
||||
import RenameFavoriteModal from './RenameFavoriteModal';
|
||||
import { TabView, Tab } from './TabView';
|
||||
import { API, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
|
||||
import { FaMusic, FaHeart } from 'react-icons/fa';
|
||||
import { API, Features, getDisplayTitle, PlaylistItem, ServerEvent } from '../api/player';
|
||||
import { FaMusic, FaHeart, FaPlus, FaEdit } from 'react-icons/fa';
|
||||
import useWebSocket from 'react-use-websocket';
|
||||
import classNames from 'classnames';
|
||||
import { useScreenShare } from '../hooks/useScreenShare';
|
||||
import AudioPlayer from './AudioPlayer';
|
||||
|
||||
enum Tabs {
|
||||
Playlist = "playlist",
|
||||
@@ -21,10 +25,11 @@ const EmptyContent: React.FC<{ label: string}> = ({label}) => (
|
||||
interface SonglistContentProps {
|
||||
songs: PlaylistItem[];
|
||||
isPlaying: boolean;
|
||||
auxControlProvider?: (song: PlaylistItem) => ReactNode;
|
||||
onNeedsRefresh: () => void;
|
||||
}
|
||||
|
||||
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onNeedsRefresh }) => {
|
||||
const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
|
||||
const handleDelete = (index: number) => {
|
||||
API.removeFromPlaylist(index);
|
||||
onNeedsRefresh();
|
||||
@@ -40,6 +45,7 @@ const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onN
|
||||
<SongTable
|
||||
songs={songs}
|
||||
isPlaying={isPlaying}
|
||||
auxControlProvider={auxControlProvider}
|
||||
onDelete={handleDelete}
|
||||
onSkipTo={handleSkipTo}
|
||||
/>
|
||||
@@ -49,9 +55,9 @@ const PlaylistContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onN
|
||||
);
|
||||
};
|
||||
|
||||
const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, onNeedsRefresh }) => {
|
||||
const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, auxControlProvider, onNeedsRefresh }) => {
|
||||
const handleDelete = (index: number) => {
|
||||
API.removeFromFavorites(index);
|
||||
API.removeFromFavorites(songs[index].filename);
|
||||
onNeedsRefresh();
|
||||
};
|
||||
|
||||
@@ -66,6 +72,7 @@ const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, on
|
||||
<SongTable
|
||||
songs={songs}
|
||||
isPlaying={isPlaying}
|
||||
auxControlProvider={auxControlProvider}
|
||||
onDelete={handleDelete}
|
||||
onSkipTo={handleSkipTo}
|
||||
/>
|
||||
@@ -77,13 +84,28 @@ const FavoritesContent: React.FC<SonglistContentProps> = ({ songs, isPlaying, on
|
||||
|
||||
const App: React.FC = () => {
|
||||
const [isPlaying, setIsPlaying] = useState(false);
|
||||
const [isIdle, setIsIdle] = useState(false);
|
||||
const [nowPlayingSong, setNowPlayingSong] = useState<string | null>(null);
|
||||
const [nowPlayingFileName, setNowPlayingFileName] = useState<string | null>(null);
|
||||
const [timePosition, setTimePosition] = useState<number | undefined>(undefined);
|
||||
const [duration, setDuration] = useState<number | undefined>(undefined);
|
||||
const [seekable, setSeekable] = useState<boolean | undefined>(undefined);
|
||||
const [volume, setVolume] = useState(100);
|
||||
const [volumeSettingIsLocked, setVolumeSettingIsLocked] = useState(false);
|
||||
const [playlist, setPlaylist] = useState<PlaylistItem[]>([]);
|
||||
const [favorites, setFavorites] = useState<PlaylistItem[]>([]);
|
||||
const [selectedTab, setSelectedTab] = useState<Tabs>(Tabs.Playlist);
|
||||
const [isRenameModalOpen, setIsRenameModalOpen] = useState(false);
|
||||
const [favoriteToRename, setFavoriteToRename] = useState<PlaylistItem | null>(null);
|
||||
const [audioEnabled, setAudioEnabled] = useState(false);
|
||||
const [features, setFeatures] = useState<Features | null>(null);
|
||||
|
||||
const {
|
||||
isScreenSharing,
|
||||
isScreenSharingSupported,
|
||||
toggleScreenShare,
|
||||
stopScreenShare
|
||||
} = useScreenShare();
|
||||
|
||||
const fetchPlaylist = useCallback(async () => {
|
||||
const playlist = await API.getPlaylist();
|
||||
@@ -107,6 +129,13 @@ const App: React.FC = () => {
|
||||
setNowPlayingFileName(nowPlaying.playingItem.filename);
|
||||
setIsPlaying(!nowPlaying.isPaused);
|
||||
setVolume(nowPlaying.volume);
|
||||
setIsIdle(nowPlaying.playingItem ? !nowPlaying.playingItem.playing : true);
|
||||
setTimePosition(nowPlaying.timePosition);
|
||||
setDuration(nowPlaying.duration);
|
||||
setSeekable(nowPlaying.seekable);
|
||||
|
||||
const features = await API.getFeatures();
|
||||
setFeatures(features);
|
||||
}, [volumeSettingIsLocked]);
|
||||
|
||||
const handleAddURL = async (url: string) => {
|
||||
@@ -136,6 +165,13 @@ const App: React.FC = () => {
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleStop = async () => {
|
||||
stopScreenShare();
|
||||
|
||||
await API.stop();
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
await API.skip();
|
||||
fetchNowPlaying();
|
||||
@@ -146,6 +182,11 @@ const App: React.FC = () => {
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleSeek = async (time: number) => {
|
||||
await API.seek(time);
|
||||
fetchNowPlaying();
|
||||
};
|
||||
|
||||
const handleVolumeSettingChange = async (volume: number) => {
|
||||
setVolume(volume);
|
||||
await API.setVolume(volume);
|
||||
@@ -173,7 +214,8 @@ const App: React.FC = () => {
|
||||
}
|
||||
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||
|
||||
useWebSocket('/api/events', {
|
||||
const wsUrl = `${window.location.protocol === 'https:' ? 'wss:' : 'ws:'}//${window.location.host}/api/events`;
|
||||
useWebSocket(wsUrl, {
|
||||
onOpen: () => {
|
||||
console.log('WebSocket connected');
|
||||
},
|
||||
@@ -215,21 +257,115 @@ const App: React.FC = () => {
|
||||
fetchFavorites();
|
||||
}, [fetchPlaylist, fetchNowPlaying, fetchFavorites]);
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
if (isPlaying) {
|
||||
fetchNowPlaying();
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearInterval(interval);
|
||||
}, [isPlaying, fetchNowPlaying]);
|
||||
|
||||
const AuxButton: React.FC<{ children: ReactNode, className: string, title: string, onClick: () => void }> = (props) => (
|
||||
<button
|
||||
className={
|
||||
classNames("hover:text-white transition-colors px-3 py-1 rounded", props.className)
|
||||
}
|
||||
title={props.title}
|
||||
onClick={props.onClick}
|
||||
>
|
||||
{props.children}
|
||||
</button>
|
||||
);
|
||||
|
||||
const playlistAuxControlProvider = (song: PlaylistItem) => {
|
||||
const isFavorite = favorites.some(f => f.filename === song.filename);
|
||||
return (
|
||||
<AuxButton
|
||||
className={classNames({
|
||||
"text-red-500": isFavorite,
|
||||
"text-white/40": !isFavorite,
|
||||
})}
|
||||
title={isFavorite ? "Remove from favorites" : "Add to favorites"}
|
||||
onClick={() => {
|
||||
if (isFavorite) {
|
||||
API.removeFromFavorites(song.filename);
|
||||
} else {
|
||||
API.addToFavorites(song.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FaHeart />
|
||||
</AuxButton>
|
||||
);
|
||||
};
|
||||
|
||||
const favoritesAuxControlProvider = (song: PlaylistItem) => {
|
||||
const isInPlaylist = playlist.some(p => p.filename === song.filename);
|
||||
return (
|
||||
<div className="flex">
|
||||
<AuxButton
|
||||
className="text-white hover:text-white"
|
||||
title="Rename favorite"
|
||||
onClick={() => {
|
||||
setFavoriteToRename(song);
|
||||
setIsRenameModalOpen(true);
|
||||
}}
|
||||
>
|
||||
<FaEdit />
|
||||
</AuxButton>
|
||||
|
||||
<AuxButton
|
||||
className={classNames({
|
||||
"text-white/40": isInPlaylist,
|
||||
"text-white": !isInPlaylist,
|
||||
})}
|
||||
title={isInPlaylist ? "Remove from playlist" : "Add to playlist"}
|
||||
onClick={() => {
|
||||
if (isInPlaylist) {
|
||||
API.removeFromPlaylist(playlist.findIndex(p => p.filename === song.filename));
|
||||
} else {
|
||||
API.addToPlaylist(song.filename);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<FaPlus />
|
||||
</AuxButton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<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">
|
||||
<NowPlaying
|
||||
className="flex flex-row md:rounded-t-2xl"
|
||||
songName={nowPlayingSong || "(Not Playing)"}
|
||||
fileName={nowPlayingFileName || ""}
|
||||
isPlaying={isPlaying}
|
||||
onPlayPause={togglePlayPause}
|
||||
onSkip={handleSkip}
|
||||
onPrevious={handlePrevious}
|
||||
{features?.browserPlayback && (
|
||||
<AudioPlayer isPlaying={isPlaying} enabled={audioEnabled} />
|
||||
)}
|
||||
|
||||
<NowPlaying
|
||||
className="flex flex-row md:rounded-t-2xl"
|
||||
songName={nowPlayingSong || "(Not Playing)"}
|
||||
fileName={nowPlayingFileName || ""}
|
||||
isPlaying={isPlaying}
|
||||
isIdle={isIdle}
|
||||
timePosition={timePosition}
|
||||
duration={duration}
|
||||
seekable={seekable}
|
||||
onPlayPause={togglePlayPause}
|
||||
onStop={handleStop}
|
||||
onSkip={handleSkip}
|
||||
onPrevious={handlePrevious}
|
||||
onSeek={handleSeek}
|
||||
onScreenShare={toggleScreenShare}
|
||||
isScreenSharing={isScreenSharing}
|
||||
volume={volume}
|
||||
onVolumeSettingChange={handleVolumeSettingChange}
|
||||
onVolumeWillChange={() => setVolumeSettingIsLocked(true)}
|
||||
onVolumeDidChange={() => setVolumeSettingIsLocked(false)}
|
||||
isScreenSharingSupported={isScreenSharingSupported}
|
||||
features={features}
|
||||
audioEnabled={audioEnabled}
|
||||
onAudioEnabledChange={setAudioEnabled}
|
||||
/>
|
||||
|
||||
<TabView selectedTab={selectedTab} onTabChange={setSelectedTab}>
|
||||
@@ -238,6 +374,7 @@ const App: React.FC = () => {
|
||||
songs={playlist}
|
||||
isPlaying={isPlaying}
|
||||
onNeedsRefresh={refreshContent}
|
||||
auxControlProvider={playlistAuxControlProvider}
|
||||
/>
|
||||
</Tab>
|
||||
<Tab label="Favorites" identifier={Tabs.Favorites} icon={<FaHeart />}>
|
||||
@@ -245,14 +382,21 @@ const App: React.FC = () => {
|
||||
songs={favorites.map(f => ({ ...f, playing: f.filename === nowPlayingFileName }))}
|
||||
isPlaying={isPlaying}
|
||||
onNeedsRefresh={refreshContent}
|
||||
auxControlProvider={favoritesAuxControlProvider}
|
||||
/>
|
||||
</Tab>
|
||||
</TabView>
|
||||
|
||||
<AddSongPanel onAddURL={handleAddURL} />
|
||||
|
||||
<RenameFavoriteModal
|
||||
isOpen={isRenameModalOpen}
|
||||
onClose={() => setIsRenameModalOpen(false)}
|
||||
favorite={favoriteToRename}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
export default App;
|
||||
32
web/frontend/src/components/AudioPlayer.tsx
Normal file
32
web/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;
|
||||
176
web/frontend/src/components/NowPlaying.tsx
Normal file
176
web/frontend/src/components/NowPlaying.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import React, { HTMLAttributes, useState, useRef } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { FaPlay, FaPause, FaStepForward, FaStepBackward, FaVolumeUp, FaDesktop, FaStop } from 'react-icons/fa';
|
||||
import { Features } from '../api/player';
|
||||
|
||||
interface NowPlayingProps extends HTMLAttributes<HTMLDivElement> {
|
||||
songName: string;
|
||||
fileName: string;
|
||||
isPlaying: boolean;
|
||||
isIdle: boolean;
|
||||
volume: number;
|
||||
timePosition?: number;
|
||||
duration?: number;
|
||||
seekable?: boolean;
|
||||
onPlayPause: () => void;
|
||||
onStop: () => void;
|
||||
onSkip: () => void;
|
||||
onPrevious: () => void;
|
||||
onSeek: (time: number) => void;
|
||||
|
||||
onScreenShare: () => void;
|
||||
isScreenSharingSupported: boolean;
|
||||
isScreenSharing: boolean;
|
||||
|
||||
// Sent when the volume setting actually changes value
|
||||
onVolumeSettingChange: (volume: number) => void;
|
||||
|
||||
// Sent when the volume is about to start changing
|
||||
onVolumeWillChange: (volume: number) => void;
|
||||
|
||||
// Sent when the volume has changed
|
||||
onVolumeDidChange: (volume: number) => void;
|
||||
|
||||
features: Features | null;
|
||||
|
||||
audioEnabled: boolean;
|
||||
onAudioEnabledChange: (enabled: boolean) => void;
|
||||
}
|
||||
|
||||
const NowPlaying: React.FC<NowPlayingProps> = (props) => {
|
||||
const [isSeeking, setIsSeeking] = useState(false);
|
||||
const progressBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const formatTime = (time: number) => {
|
||||
const minutes = Math.floor(time / 60);
|
||||
const seconds = Math.floor(time % 60);
|
||||
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||
if (progressBarRef.current) {
|
||||
const rect = progressBarRef.current.getBoundingClientRect();
|
||||
const newSeekPosition = (e.clientX - rect.left) / rect.width;
|
||||
if (props.duration) {
|
||||
props.onSeek(newSeekPosition * props.duration);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const titleArea = props.isScreenSharing ? (
|
||||
<div className="flex flex-row items-center gap-2 text-white text-center justify-center">
|
||||
<FaDesktop size={24} />
|
||||
<div className="text-lg font-bold truncate">Screen Sharing</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames(props.isIdle ? 'opacity-50' : 'opacity-100', "flex flex-row items-center justify-between gap-2 w-full")}>
|
||||
<div className="truncate">
|
||||
<div className="text-lg font-bold truncate">{props.songName}</div>
|
||||
<div className="text-sm truncate">{props.fileName}</div>
|
||||
</div>
|
||||
<div className="text-sm opacity-50 shrink-0">
|
||||
{props.timePosition && props.duration ?
|
||||
(props.seekable ? `${formatTime(props.timePosition)} / ${formatTime(props.duration)}`
|
||||
: `${formatTime(props.timePosition)}` )
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(props.className, 'bg-black/50 h-fit p-5')}>
|
||||
<div className="flex flex-col w-full gap-2">
|
||||
<div className="flex flex-col w-full h-full bg-black/50 rounded-lg gap-4 overflow-hidden">
|
||||
<div className="p-5">
|
||||
<div className="flex-grow min-w-0 w-full text-white text-left">
|
||||
{titleArea}
|
||||
</div>
|
||||
<div className="flex flex-row items-center gap-4 w-full pt-4">
|
||||
<div className="flex items-center gap-2 text-white w-full max-w-[250px]">
|
||||
<FaVolumeUp size={20} />
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
value={props.volume}
|
||||
onMouseDown={() => props.onVolumeWillChange(props.volume)}
|
||||
onMouseUp={() => props.onVolumeDidChange(props.volume)}
|
||||
onChange={(e) => props.onVolumeSettingChange(Number(e.target.value))}
|
||||
className="fancy-slider h-2 w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex-grow"></div>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPrevious}>
|
||||
<FaStepBackward size={24} />
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onPlayPause}>
|
||||
{(props.isPlaying && !props.isIdle) ? <FaPause size={24} /> : <FaPlay size={24} />}
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onStop}>
|
||||
<FaStop size={24} className={props.isIdle ? 'opacity-25' : 'opacity-100'} />
|
||||
</button>
|
||||
|
||||
<button className="text-white hover:text-violet-300 transition-colors" onClick={props.onSkip}>
|
||||
<FaStepForward size={24} />
|
||||
</button>
|
||||
|
||||
{(props.isScreenSharingSupported && props.features?.screenshare) && (
|
||||
<button
|
||||
className={classNames("text-white hover:text-violet-300 transition-colors rounded-full p-2", props.isScreenSharing ? ' bg-violet-800' : '')}
|
||||
onClick={props.onScreenShare}
|
||||
title="Share your screen"
|
||||
>
|
||||
<FaDesktop size={24} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{props.seekable !== false && (
|
||||
<div
|
||||
ref={progressBarRef}
|
||||
className="w-full h-2 bg-gray-600 cursor-pointer -mt-3"
|
||||
onMouseDown={(e) => {
|
||||
setIsSeeking(true);
|
||||
handleSeek(e);
|
||||
}}
|
||||
onMouseMove={(e) => {
|
||||
if (isSeeking) {
|
||||
handleSeek(e);
|
||||
}
|
||||
}}
|
||||
onMouseUp={() => setIsSeeking(false)}
|
||||
onMouseLeave={() => setIsSeeking(false)}
|
||||
>
|
||||
<div
|
||||
className="h-full bg-violet-500"
|
||||
style={{ width: `${(props.timePosition && props.duration ? (props.timePosition / props.duration) * 100 : 0)}%` }}
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
export default NowPlaying;
|
||||
97
web/frontend/src/components/RenameFavoriteModal.tsx
Normal file
97
web/frontend/src/components/RenameFavoriteModal.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import React, { useState, KeyboardEvent, useEffect } from 'react';
|
||||
import { FaTimes, FaCheck } from 'react-icons/fa';
|
||||
import { API, PlaylistItem, getDisplayTitle } from '../api/player';
|
||||
|
||||
interface RenameFavoriteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
favorite: PlaylistItem | null;
|
||||
}
|
||||
|
||||
const RenameFavoriteModal: React.FC<RenameFavoriteModalProps> = ({ isOpen, onClose, favorite }) => {
|
||||
const [title, setTitle] = useState('');
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (favorite) {
|
||||
setTitle(getDisplayTitle(favorite));
|
||||
}
|
||||
}, [favorite]);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!favorite || !title.trim()) return;
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
await API.updateFavoriteTitle(favorite.filename, title);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error('Failed to rename favorite:', error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSave();
|
||||
} else if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !favorite) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||
<div className="bg-violet-900 w-full max-w-md rounded-lg p-4 shadow-lg">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-white text-xl font-bold">Rename Favorite</h2>
|
||||
|
||||
<button onClick={onClose} className="text-white/60 hover:text-white">
|
||||
<FaTimes size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="text-white/80 block mb-2">Original filename:</label>
|
||||
<div className="text-white/60 text-sm truncate bg-black/20 p-2 rounded-lg mb-4">
|
||||
{favorite.filename}
|
||||
</div>
|
||||
|
||||
<label className="text-white/80 block mb-2">Title:</label>
|
||||
<input
|
||||
type="text"
|
||||
autoFocus
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Enter new title..."
|
||||
className="p-2 rounded-lg border-2 border-violet-500 w-full bg-black/20 text-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="bg-black/30 text-white p-2 rounded-lg px-4 hover:bg-black/40"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving || !title.trim()}
|
||||
className="bg-violet-500 text-white p-2 rounded-lg px-4 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<FaCheck size={16} />
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RenameFavoriteModal;
|
||||
@@ -1,5 +1,5 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import React, { useState, useRef, useEffect, ReactNode } from 'react';
|
||||
import { FaPlay, FaVolumeUp, FaVolumeOff } from 'react-icons/fa';
|
||||
import { getDisplayTitle, PlaylistItem } from '../api/player';
|
||||
|
||||
@@ -11,12 +11,13 @@ export enum PlayState {
|
||||
|
||||
export interface SongRowProps {
|
||||
song: PlaylistItem;
|
||||
auxControl?: ReactNode;
|
||||
playState: PlayState;
|
||||
onDelete: () => void;
|
||||
onPlay: () => void;
|
||||
}
|
||||
|
||||
const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay }) => {
|
||||
const SongRow: React.FC<SongRowProps> = ({ song, auxControl, playState, onDelete, onPlay }) => {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
@@ -39,7 +40,8 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
||||
const displayTitle = getDisplayTitle(song);
|
||||
|
||||
return (
|
||||
<div className={classNames("flex flex-row w-full h-24 px-2 py-5 items-center border-b gap-2 transition-colors", {
|
||||
<div className={classNames(
|
||||
"flex flex-row w-full h-20 px-2 py-2 items-center border-b gap-2 transition-colors shrink-0", {
|
||||
"qc-highlighted": (playState === PlayState.Playing || playState === PlayState.Paused),
|
||||
"bg-black/30": playState === PlayState.NotPlaying,
|
||||
})}>
|
||||
@@ -76,6 +78,8 @@ const SongRow: React.FC<SongRowProps> = ({ song, playState, onDelete, onPlay })
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row gap-2">
|
||||
{auxControl}
|
||||
|
||||
<button
|
||||
ref={buttonRef}
|
||||
className="text-red-100 px-3 py-1 bg-red-500/40 rounded"
|
||||
@@ -1,15 +1,16 @@
|
||||
import React, { useEffect, useRef } from "react";
|
||||
import React, { useEffect, useRef, ReactNode } from "react";
|
||||
import SongRow, { PlayState } from "./SongRow";
|
||||
import { PlaylistItem } from "../api/player";
|
||||
|
||||
interface SongTableProps {
|
||||
songs: PlaylistItem[];
|
||||
isPlaying: boolean;
|
||||
isPlaying: boolean
|
||||
auxControlProvider?: (song: PlaylistItem) => ReactNode;
|
||||
onDelete: (index: number) => void;
|
||||
onSkipTo: (index: number) => void;
|
||||
}
|
||||
|
||||
const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, onDelete, onSkipTo }) => {
|
||||
const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, auxControlProvider, onDelete, onSkipTo }) => {
|
||||
const nowPlayingIndex = songs.findIndex(song => song.playing ?? false);
|
||||
const songTableRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -21,11 +22,12 @@ const SongTable: React.FC<SongTableProps> = ({ songs, isPlaying, onDelete, onSki
|
||||
}, [nowPlayingIndex]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full h-full overflow-y-auto border-y" ref={songTableRef}>
|
||||
<div className="flex flex-col w-full h-full overflow-y-auto" ref={songTableRef}>
|
||||
{songs.map((song, index) => (
|
||||
<SongRow
|
||||
key={index}
|
||||
song={song}
|
||||
auxControl={auxControlProvider ? auxControlProvider(song) : undefined}
|
||||
playState={
|
||||
(song.playing ?? false) ? (isPlaying ? PlayState.Playing : PlayState.Paused)
|
||||
: PlayState.NotPlaying
|
||||
@@ -26,7 +26,7 @@ export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps
|
||||
) as React.ReactElement<TabProps<T>>[];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col flex-1 overflow-hidden">
|
||||
<div className="flex flex-row h-11 border-b border-white/20">
|
||||
{tabs.map((tab, index) => {
|
||||
const isSelected = selectedTab === tab.props.identifier;
|
||||
@@ -47,7 +47,7 @@ export const TabView = <T,>({ children, selectedTab, onTabChange }: TabViewProps
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="flex-1 overflow-hidden">
|
||||
{tabs.map((tab, index) => (
|
||||
<div
|
||||
key={index}
|
||||
24
web/frontend/src/config.ts
Normal file
24
web/frontend/src/config.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* config.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License,
|
||||
* or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
export const USE_INVIDIOUS = import.meta.env.VITE_USE_INVIDIOUS || true;
|
||||
export const INVIDIOUS_BASE_URL = import.meta.env.VITE_INVIDIOUS_BASE_URL || 'http://invidious.nor';
|
||||
export const INVIDIOUS_API_ENDPOINT = `${INVIDIOUS_BASE_URL}/api/v1`;
|
||||
|
||||
export const getInvidiousSearchURL = (query: string): string =>
|
||||
`${INVIDIOUS_API_ENDPOINT}/search?q=${encodeURIComponent(query)}`;
|
||||
136
web/frontend/src/hooks/useScreenShare.ts
Normal file
136
web/frontend/src/hooks/useScreenShare.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { API } from '../api/player';
|
||||
|
||||
interface UseScreenShareResult {
|
||||
isScreenSharing: boolean;
|
||||
isScreenSharingSupported: boolean;
|
||||
toggleScreenShare: () => Promise<void>;
|
||||
stopScreenShare: () => void;
|
||||
}
|
||||
|
||||
function getBestSupportedMimeType() {
|
||||
// Ordered by preference (best first) - all of these include audio+video
|
||||
const mimeTypes = [
|
||||
'video/webm;codecs=vp9,opus', // Best quality, good compression
|
||||
'video/webm;codecs=vp8,opus', // Good fallback, well supported
|
||||
'video/webm;codecs=h264,opus', // Better compatibility with some systems
|
||||
'video/mp4;codecs=h264,aac', // Good for Safari but may not be supported for MediaRecorder
|
||||
'video/webm', // Generic fallback (browser will choose codecs)
|
||||
'video/mp4' // Last resort
|
||||
];
|
||||
|
||||
// Find the first supported mimetype
|
||||
for (const type of mimeTypes) {
|
||||
if (MediaRecorder.isTypeSupported(type)) {
|
||||
console.log(`Using mime type: ${type}`);
|
||||
return type;
|
||||
}
|
||||
}
|
||||
|
||||
// If none are supported, return null or a basic fallback
|
||||
console.warn('No preferred mime types supported by this browser');
|
||||
return 'video/webm'; // Most basic fallback
|
||||
}
|
||||
|
||||
export const useScreenShare = (): UseScreenShareResult => {
|
||||
const [isScreenSharing, setIsScreenSharing] = useState(false);
|
||||
const [isScreenSharingSupported, setIsScreenSharingSupported] = useState(false);
|
||||
const screenShareSocketRef = useRef<WebSocket | null>(null);
|
||||
|
||||
// Check if screen sharing is supported
|
||||
useEffect(() => {
|
||||
setIsScreenSharingSupported(
|
||||
typeof navigator !== 'undefined' &&
|
||||
navigator.mediaDevices !== undefined &&
|
||||
typeof navigator.mediaDevices.getDisplayMedia === 'function'
|
||||
);
|
||||
}, []);
|
||||
|
||||
const stopScreenShare = useCallback(() => {
|
||||
if (screenShareSocketRef.current) {
|
||||
screenShareSocketRef.current.close();
|
||||
screenShareSocketRef.current = null;
|
||||
}
|
||||
setIsScreenSharing(false);
|
||||
}, []);
|
||||
|
||||
const startScreenShare = useCallback(async () => {
|
||||
try {
|
||||
const mediaStream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: true,
|
||||
audio: true,
|
||||
});
|
||||
|
||||
let mimeType = getBestSupportedMimeType();
|
||||
console.log('Using MIME type:', mimeType);
|
||||
|
||||
const mediaRecorder = new MediaRecorder(mediaStream, {
|
||||
mimeType: mimeType,
|
||||
videoBitsPerSecond: 2500000, // 2.5 Mbps
|
||||
audioBitsPerSecond: 128000, // 128 kbps
|
||||
});
|
||||
|
||||
// Connect to WebSocket
|
||||
screenShareSocketRef.current = API.startScreenShare(mimeType);
|
||||
|
||||
// Set up WebSocket event handlers
|
||||
screenShareSocketRef.current.onopen = () => {
|
||||
console.log('Screen sharing WebSocket connected');
|
||||
setIsScreenSharing(true);
|
||||
|
||||
mediaRecorder.start(100);
|
||||
};
|
||||
|
||||
screenShareSocketRef.current.onclose = () => {
|
||||
console.log('Screen sharing WebSocket closed');
|
||||
setIsScreenSharing(false);
|
||||
|
||||
// Stop all tracks when WebSocket is closed
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
screenShareSocketRef.current.onerror = (error) => {
|
||||
console.error('Screen sharing WebSocket error:', error);
|
||||
setIsScreenSharing(false);
|
||||
|
||||
// Stop all tracks on error
|
||||
mediaStream.getTracks().forEach(track => track.stop());
|
||||
};
|
||||
|
||||
// Send data over WebSocket when available
|
||||
mediaRecorder.ondataavailable = (event) => {
|
||||
if (event.data && event.data.size > 0 && screenShareSocketRef.current && screenShareSocketRef.current.readyState === WebSocket.OPEN) {
|
||||
screenShareSocketRef.current.send(event.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle stream ending (user clicks "Stop sharing")
|
||||
mediaStream.getVideoTracks()[0].onended = () => {
|
||||
if (screenShareSocketRef.current) {
|
||||
screenShareSocketRef.current.close();
|
||||
screenShareSocketRef.current = null;
|
||||
}
|
||||
setIsScreenSharing(false);
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error starting screen share:', error);
|
||||
setIsScreenSharing(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const toggleScreenShare = useCallback(async () => {
|
||||
if (screenShareSocketRef.current) {
|
||||
stopScreenShare();
|
||||
} else {
|
||||
await startScreenShare();
|
||||
}
|
||||
}, [startScreenShare, stopScreenShare]);
|
||||
|
||||
return {
|
||||
isScreenSharing,
|
||||
isScreenSharingSupported,
|
||||
toggleScreenShare,
|
||||
stopScreenShare
|
||||
};
|
||||
};
|
||||
19
web/frontend/src/vite-env.d.ts
vendored
Normal file
19
web/frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* vite-env.d.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License,
|
||||
* or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
/// <reference types="vite/client" />
|
||||
36
web/frontend/vite.config.ts
Normal file
36
web/frontend/vite.config.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* vite.config.ts
|
||||
* Copyleft 2025 James Magahern <buzzert@buzzert.net>
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published
|
||||
* by the Free Software Foundation, either version 3 of the License,
|
||||
* or (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
* General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
// For development only: proxy /api to backend running on separate port.
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
ws: true
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
1678
package-lock.json → web/package-lock.json
generated
1678
package-lock.json → web/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
|
Before Width: | Height: | Size: 196 KiB After Width: | Height: | Size: 196 KiB |
Reference in New Issue
Block a user