mirror of
https://github.com/janishutz/MusicPlayer.git
synced 2026-09-10 14:25:24 +02:00
Compare commits
17
Commits
4d4ddfe255
..
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c27be5f42c | ||
|
|
5e322f6c67 | ||
|
|
8d6d24a780 | ||
|
|
946ba84518 | ||
|
|
be2a9f3a3e | ||
|
|
d573ac90eb | ||
|
|
b11882c993 | ||
|
|
047a3a8b29 | ||
|
|
6d9853f2c1 | ||
|
|
054fda2fb9 | ||
|
|
867374fa47 | ||
|
|
a5910df441 | ||
|
|
cb5cdc483f | ||
|
|
18bdf82bcf | ||
|
|
68bbfa7896 | ||
|
|
8a83326d60 | ||
|
|
ab08a94d55 |
@@ -1,6 +1,7 @@
|
|||||||
# ignore node_modules
|
# ignore node_modules
|
||||||
node_modules
|
node_modules
|
||||||
*.secret.json
|
*.secret.json
|
||||||
|
*.secret.json
|
||||||
apple_private_key.p8
|
apple_private_key.p8
|
||||||
musicplayerv2-server.zip
|
musicplayerv2-server.zip
|
||||||
dist
|
dist
|
||||||
|
|||||||
@@ -4,21 +4,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="badges" align="center">
|
<div id="badges" align="center">
|
||||||
<img alt="Project License" src="https://img.shields.io/github/license/simplePCBuilding/MusicPlayerV2.svg">
|
<img alt="Project License" src="https://img.shields.io/github/license/janishutz/MusicPlayer.svg">
|
||||||
<img alt="GitHub Repo size" src="https://img.shields.io/github/repo-size/simplePCBuilding/MusicPlayerV2.svg">
|
<img alt="GitHub Repo size" src="https://img.shields.io/github/repo-size/janishutz/MusicPlayer.svg">
|
||||||
<img alt="GitHub Repo issues" src="https://img.shields.io/github/issues-pr-raw/simplePCBuilding/MusicPlayerV2">
|
<img alt="GitHub Repo issues" src="https://img.shields.io/github/issues-pr-raw/janishutz/MusicPlayer">
|
||||||
<img alt="Top Languages" src="https://img.shields.io/github/languages/top/simplePCBuilding/MusicPlayerV2">
|
<img alt="Top Languages" src="https://img.shields.io/github/languages/top/janishutz/MusicPlayer">
|
||||||
<img alt="GitHub Repo filecount" src="https://img.shields.io/github/directory-file-count/simplePCBuilding/MusicPlayerV2.svg">
|
<img alt="GitHub Repo filecount" src="https://img.shields.io/github/directory-file-count/janishutz/MusicPlayer.svg">
|
||||||
<br>
|
<br>
|
||||||
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/simplePCBuilding/MusicPlayerV2">
|
<img alt="GitHub Repo stars" src="https://img.shields.io/github/stars/janishutz/MusicPlayer">
|
||||||
<img alt="GitHub watchers" src="https://img.shields.io/github/watchers/simplePCBuilding/MusicPlayerV2">
|
<img alt="GitHub watchers" src="https://img.shields.io/github/watchers/janishutz/MusicPlayer">
|
||||||
<img alt="GitHub forks" src="https://img.shields.io/github/forks/simplePCBuilding/MusicPlayerV2">
|
<img alt="GitHub forks" src="https://img.shields.io/github/forks/janishutz/MusicPlayer">
|
||||||
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/simplePCBuilding/MusicPlayerV2">
|
<img alt="GitHub commit activity" src="https://img.shields.io/github/commit-activity/m/janishutz/MusicPlayer">
|
||||||
<br>
|
<br>
|
||||||
<img alt="GitHub all releases" src="https://img.shields.io/github/downloads/simplePCBuilding/MusicPlayerV2/total?label=Downloads (total)">
|
<img alt="GitHub all releases" src="https://img.shields.io/github/downloads/janishutz/MusicPlayer/total?label=Downloads (total)">
|
||||||
<img alt="GitHub release (latest by date)" src="https://img.shields.io/github/downloads/simplePCBuilding/MusicPlayerV2/latest/total?label=Downloads (latest)">
|
<img alt="GitHub release (latest by date)" src="https://img.shields.io/github/downloads/janishutz/MusicPlayer/latest/total?label=Downloads (latest)">
|
||||||
<img alt="Latest release" src="https://img.shields.io/github/release/simplePCBuilding/MusicPlayerV2.svg">
|
<img alt="Latest release" src="https://img.shields.io/github/release/janishutz/MusicPlayer.svg">
|
||||||
<img alt="App Version" src="https://img.shields.io/github/package-json/v/simplePCBuilding/MusicPlayerV2.svg?label=Development Version">
|
<img alt="App Version" src="https://img.shields.io/github/package-json/v/janishutz/MusicPlayer.svg?label=Development Version">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
# Development Branch
|
# Development Branch
|
||||||
@@ -38,6 +38,7 @@ The [hosted version](https://music.janishutz.com) of this MusicPlayer, which is
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
**NOTICE:** This section has not been updated for the new version of MusicPlayer yet
|
||||||
- Browser based App that runs on all OS (Linux, MacOS, Windows, iOS, Android, iPadOS, ...)
|
- Browser based App that runs on all OS (Linux, MacOS, Windows, iOS, Android, iPadOS, ...)
|
||||||
- Fully featured Music Player
|
- Fully featured Music Player
|
||||||
- Show all song information over the Internet on any amount of client displays
|
- Show all song information over the Internet on any amount of client displays
|
||||||
|
|||||||
@@ -24,5 +24,10 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "The URL to the frontend. Just the host, in form https://music.janishutz.com"
|
"description": "The URL to the frontend. Just the host, in form https://music.janishutz.com"
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"required": [
|
||||||
|
"mode",
|
||||||
|
"clientMode",
|
||||||
|
"webUrl"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
{
|
{
|
||||||
"$schema": "../config.schema.json",
|
"$schema": "../config.schema.json",
|
||||||
"mode": "foss"
|
"mode": "hosted",
|
||||||
|
"clientMode": "sse",
|
||||||
|
"webUrl": "http://localhost:8081"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{"playlists":[{"name":"Hello World","songs":[{"duration":236,"name":"CAN'T STOP THE FEELING! (Original Song From DreamWorks Animation's \"TROLLS\")","additional-info":"Test 3","artist":"Justin Timberlake","artwork":"https://is1-ssl.mzstatic.com/image/thumb/Music124/v4/52/d0/02/52d002d7-1f7f-8d91-4791-ed04c460ec93/886445894653.jpg/2000x2000bb.jpg","identifier":"blob:http://localhost:8081/fdb6fccd-30cb-458c-a9f6-c424d2f53c43","source":"local","additional-identifier":"16_Can't_Stop_The_Feeling-Justin_Timberlake.mp3__4407330"},{"duration":258,"name":"Muévelo","additional-info":"Test","artist":"Rey Ruiz","artwork":"https://is1-ssl.mzstatic.com/image/thumb/Music122/v4/58/15/0d/58150db1-0e74-dd19-f7eb-3f15e8a04b56/197187182459.jpg/2000x2000bb.jpg","identifier":"blob:http://localhost:8081/18dcea36-839d-4714-b7fc-6800f26e07b9","source":"local","additional-identifier":"18_Muevlo-Rey_Ruiz.mp3__4831574"}]}],"userid":"server-stubs","version":"1"}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
"@janishutz/login-sdk-server": "^1.4.0",
|
"@janishutz/login-sdk-server": "^1.4.0",
|
||||||
"@janishutz/login-sdk-server-stubs": "^1.4.0",
|
"@janishutz/login-sdk-server-stubs": "^1.4.0",
|
||||||
"@janishutz/store-sdk": "^1.2.1",
|
"@janishutz/store-sdk": "^1.2.1",
|
||||||
|
"body-parser": "^2.3.0",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
"express-session": "^1.19.0",
|
"express-session": "^1.19.0",
|
||||||
@@ -12,6 +13,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@stylistic/eslint-plugin": "^5.10.0",
|
"@stylistic/eslint-plugin": "^5.10.0",
|
||||||
|
"@types/body-parser": "^1.19.6",
|
||||||
"@types/cors": "^2.8.19",
|
"@types/cors": "^2.8.19",
|
||||||
"@types/express": "^5.0.6",
|
"@types/express": "^5.0.6",
|
||||||
"@types/express-session": "^1.19.0",
|
"@types/express-session": "^1.19.0",
|
||||||
|
|||||||
+31
-15
@@ -2,37 +2,51 @@ import {
|
|||||||
getLoginSdk,
|
getLoginSdk,
|
||||||
getStoreSdk
|
getStoreSdk
|
||||||
} from './sdk';
|
} from './sdk';
|
||||||
|
import {
|
||||||
|
Config
|
||||||
|
} from './dtype/config';
|
||||||
import devtoken from './routes/devtoken';
|
import devtoken from './routes/devtoken';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import logger from './logger';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import room from './routes/room';
|
||||||
// TODO: configurable
|
import user from './routes/user';
|
||||||
const sdk = getLoginSdk( false );
|
|
||||||
const storeSdk = getStoreSdk( false );
|
|
||||||
|
|
||||||
|
|
||||||
const run = () => {
|
const run = () => {
|
||||||
const app = express();
|
// FIXME: Use DB instead of memory optionally
|
||||||
|
const config = JSON.parse( fs.readFileSync( path.join(
|
||||||
|
__dirname,
|
||||||
|
'/../config/config.json'
|
||||||
|
) ).toString() ) as Config;
|
||||||
|
const foss = config.mode !== 'hosted';
|
||||||
const sdkConfig = JSON.parse( fs.readFileSync( path.join(
|
const sdkConfig = JSON.parse( fs.readFileSync( path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
'/config/sdk.config.testing.json'
|
`/../config/sdk.config.${ foss ? 'testing' : 'secret' }.json`
|
||||||
) ).toString() );
|
) ).toString() );
|
||||||
|
const sdk = getLoginSdk( foss );
|
||||||
|
const storeSdk = getStoreSdk( foss );
|
||||||
|
const app = express();
|
||||||
|
|
||||||
|
logger.info( 'Starting in', foss ? 'FOSS' : 'hosted', 'mode' );
|
||||||
|
|
||||||
|
app.use( logger.routeLogging() );
|
||||||
|
|
||||||
// Load id.janishutz.com SDK and allow signing in
|
// Load id.janishutz.com SDK and allow signing in
|
||||||
sdk.setUp(
|
sdk.setUp(
|
||||||
{
|
{
|
||||||
'prod': false,
|
'prod': false,
|
||||||
'service': {
|
'service': {
|
||||||
'serviceID': 'jh-music',
|
'serviceID': sdkConfig[ 'name' ],
|
||||||
'serviceToken': sdkConfig[ 'token' ]
|
'serviceToken': sdkConfig[ 'token' ]
|
||||||
},
|
},
|
||||||
'user-agent': sdkConfig[ 'ua' ],
|
'user-agent': sdkConfig[ 'ua' ],
|
||||||
'sessionType': 'memory',
|
'sessionType': 'memory',
|
||||||
'frontendURL': 'https://music.janishutz.com',
|
'frontendURL': config.webUrl,
|
||||||
'corsWhitelist': [ 'https://music.janishutz.com' ],
|
'corsWhitelist': [ config.webUrl ],
|
||||||
'recheckTimeout': 300 * 1000,
|
'recheckTimeout': 300 * 1000,
|
||||||
'advancedVerification': 'sdk'
|
'advancedVerification': 'sdk',
|
||||||
|
'defaultRedirectURL': '/app'
|
||||||
},
|
},
|
||||||
app,
|
app,
|
||||||
async () => {
|
async () => {
|
||||||
@@ -55,22 +69,24 @@ const run = () => {
|
|||||||
// Load store sdk
|
// Load store sdk
|
||||||
const storeConfig = JSON.parse( fs.readFileSync( path.join(
|
const storeConfig = JSON.parse( fs.readFileSync( path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
'/config/store-sdk.config.secret.json'
|
`/../config/store-sdk.config.${ foss ? 'testing' : 'secret' }.json`
|
||||||
) ).toString() );
|
) ).toString() );
|
||||||
|
|
||||||
storeSdk.configure( storeConfig );
|
storeSdk.configure( storeConfig );
|
||||||
|
|
||||||
app.get( '/', ( _request: express.Request, response: express.Response ) => {
|
app.get( '/', ( _request: express.Request, response: express.Response ) => {
|
||||||
response.send( 'HELLO WORLD' );
|
response.redirect( config.webUrl ?? 'https://music.janishutz.com' );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
|
||||||
// Load extra routes
|
// Load extra routes
|
||||||
devtoken.routes( app );
|
devtoken.routes( app, foss );
|
||||||
|
room.routes( app, foss, config );
|
||||||
|
user.routes( app, foss );
|
||||||
|
|
||||||
|
|
||||||
app.use( ( _request: express.Request, response: express.Response ) => {
|
app.use( ( _request: express.Request, response: express.Response ) => {
|
||||||
response.sendFile( path.join( __dirname, '' ) );
|
response.sendStatus( 404 );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
const PORT = process.env.PORT || 8080;
|
const PORT = process.env.PORT || 8080;
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
CorsOptions
|
||||||
|
} from 'cors';
|
||||||
|
import cors from 'cors';
|
||||||
|
|
||||||
|
let corsWhitelist: string[] = [];
|
||||||
|
|
||||||
|
const configure = ( whitelist: string[] ) => {
|
||||||
|
corsWhitelist = whitelist;
|
||||||
|
};
|
||||||
|
|
||||||
|
const middleware = ( rest: boolean = false ) => {
|
||||||
|
return cors( genCorsOpts( rest ) );
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate the options for the CORS library
|
||||||
|
* @param rest - [OPTIONAL] Whether to allow REST requests or not
|
||||||
|
* @returns CORS options object that can be used for the cors library
|
||||||
|
*/
|
||||||
|
const genCorsOpts = ( rest: boolean = false ): CorsOptions => {
|
||||||
|
const corsOpts: CorsOptions = {
|
||||||
|
'credentials': true,
|
||||||
|
'optionsSuccessStatus': 200,
|
||||||
|
'origin': ( origin, cb ) => {
|
||||||
|
const status = blockREST( origin! );
|
||||||
|
|
||||||
|
cb( null, status );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if ( rest ) {
|
||||||
|
corsOpts[ 'origin' ] = ( origin, cb ) => {
|
||||||
|
const status = allowREST( origin! );
|
||||||
|
|
||||||
|
cb( null, status );
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return corsOpts;
|
||||||
|
};
|
||||||
|
|
||||||
|
const blockREST = ( origin: string ): boolean => {
|
||||||
|
return corsWhitelist.includes( origin );
|
||||||
|
};
|
||||||
|
|
||||||
|
const allowREST = ( origin: string ): boolean => {
|
||||||
|
return blockREST( origin ) || !origin;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
export default {
|
||||||
|
genCorsOpts,
|
||||||
|
configure,
|
||||||
|
middleware
|
||||||
|
};
|
||||||
Vendored
+5
@@ -0,0 +1,5 @@
|
|||||||
|
export interface Config {
|
||||||
|
'mode': 'foss' | 'hosted';
|
||||||
|
'clientMode': 'sse' | 'poll';
|
||||||
|
'webUrl': string;
|
||||||
|
}
|
||||||
+23
-14
@@ -1,29 +1,37 @@
|
|||||||
|
import express from 'express';
|
||||||
import {
|
import {
|
||||||
writeFile
|
writeFile
|
||||||
} from 'node:fs';
|
} from 'node:fs';
|
||||||
|
|
||||||
const log = ( ...msg: string[] ) => {
|
const log = ( ...msg: unknown[] ) => {
|
||||||
output( 'log', log.caller.toString(), ...msg );
|
output( 'log', 'UNKNOWN', ...msg );
|
||||||
};
|
};
|
||||||
|
|
||||||
const info = ( ...msg: string[] ) => {
|
const info = ( ...msg: unknown[] ) => {
|
||||||
output( 'info', log.caller.toString(), ...msg );
|
output( 'info', 'UNKNOWN', ...msg );
|
||||||
};
|
};
|
||||||
|
|
||||||
const debug = ( ...msg: string[] ) => {
|
const debug = ( ...msg: unknown[] ) => {
|
||||||
output( 'debug', log.caller.toString(), ...msg );
|
output( 'debug', 'UNKNOWN', ...msg );
|
||||||
};
|
};
|
||||||
|
|
||||||
const warn = ( ...msg: string[] ) => {
|
const warn = ( ...msg: unknown[] ) => {
|
||||||
output( 'warn', log.caller.toString(), ...msg );
|
output( 'warn', 'UNKNOWN', ...msg );
|
||||||
};
|
};
|
||||||
|
|
||||||
const error = ( ...msg: string[] ) => {
|
const error = ( ...msg: unknown[] ) => {
|
||||||
output( 'error', log.caller.toString(), ...msg );
|
output( 'error', 'UNKNOWN', ...msg );
|
||||||
};
|
};
|
||||||
|
|
||||||
const fatal = ( ...msg: string[] ) => {
|
const fatal = ( ...msg: unknown[] ) => {
|
||||||
output( 'fatal', log.caller.toString(), ...msg );
|
output( 'fatal', 'UNKNOWN', ...msg );
|
||||||
|
};
|
||||||
|
|
||||||
|
const routeLogging = () => {
|
||||||
|
return ( request: express.Request, _response: express.Response, next: express.NextFunction ) => {
|
||||||
|
output( 'info', 'ROUTER', request.originalUrl, 'from', request.headers['user-agent'] ?? 'No UA' );
|
||||||
|
next();
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -51,7 +59,7 @@ const configure = ( location: 'stderr' | 'file', minLevel: LogLevel, file?: stri
|
|||||||
|
|
||||||
const logfile: string[] = [];
|
const logfile: string[] = [];
|
||||||
|
|
||||||
const output = ( level: LogLevel, caller: string, ...message: string[] ) => {
|
const output = ( level: LogLevel, caller: string, ...message: unknown[] ) => {
|
||||||
if ( levels.indexOf( level ) < lev ) {
|
if ( levels.indexOf( level ) < lev ) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -100,5 +108,6 @@ export default {
|
|||||||
warn,
|
warn,
|
||||||
error,
|
error,
|
||||||
fatal,
|
fatal,
|
||||||
configure
|
configure,
|
||||||
|
routeLogging
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,186 @@
|
|||||||
import {
|
import {
|
||||||
|
Client,
|
||||||
|
Room,
|
||||||
RoomStore
|
RoomStore
|
||||||
} from './room';
|
} from './room';
|
||||||
|
import {
|
||||||
|
PlaylistSongs
|
||||||
|
} from '../../dtype/file';
|
||||||
|
import {
|
||||||
|
Response
|
||||||
|
} from 'express';
|
||||||
|
|
||||||
const rooms: RoomStore = {};
|
const rooms: RoomStore = {};
|
||||||
|
|
||||||
const getRoom = ( room: string ) => {};
|
/**
|
||||||
|
* The room to get
|
||||||
|
* @param room - The name of the room to get
|
||||||
|
* @returns The room, or undefined if it is not present
|
||||||
|
*/
|
||||||
|
const get = ( room: string ): Room | undefined => {
|
||||||
|
return rooms[room];
|
||||||
|
};
|
||||||
|
|
||||||
const createRoom = ( name: string ) => {};
|
/**
|
||||||
|
* @param name - The name of the room to create
|
||||||
|
* @param uid - The ID of the user
|
||||||
|
*/
|
||||||
|
const create = ( name: string, uid: string ) => {
|
||||||
|
if ( rooms[ name ] ) {
|
||||||
|
return false;
|
||||||
|
} else {
|
||||||
|
rooms[ name ] = {
|
||||||
|
'clients': [],
|
||||||
|
'trackingClients': [],
|
||||||
|
'owner': uid,
|
||||||
|
'playlist': {
|
||||||
|
'playlist': [],
|
||||||
|
'lastUpdate': new Date().getTime()
|
||||||
|
},
|
||||||
|
'state': {
|
||||||
|
'index': 0,
|
||||||
|
'lastUpdate': new Date().getTime(),
|
||||||
|
'playing': false,
|
||||||
|
'start': new Date().getTime(),
|
||||||
|
'offset': 0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a listening client to a room
|
||||||
|
* @param roomName - The room ID this client should belong to
|
||||||
|
* @param id - The ID of the client
|
||||||
|
* @param kind - The type of client that is to be removed
|
||||||
|
* @param response - The express response object
|
||||||
|
* @returns true if successful, false otherwise
|
||||||
|
*/
|
||||||
|
const addClient = ( roomName: string, id: string, kind: 'client' | 'trackingClient', response: Response ) => {
|
||||||
|
if ( !rooms[roomName] ) return false;
|
||||||
|
|
||||||
|
const room = rooms[roomName][kind + 's' as 'clients' | 'trackingClients'] as Client[];
|
||||||
|
|
||||||
|
room.push( {
|
||||||
|
'response': response,
|
||||||
|
'id': id
|
||||||
|
} );
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a listening client by its ID
|
||||||
|
* @param roomName - The room ID this client belongs to
|
||||||
|
* @param id - The ID of the client
|
||||||
|
* @param kind - The type of client that is to be removed
|
||||||
|
* @returns true if successful, false otherwise
|
||||||
|
*/
|
||||||
|
const removeClient = ( roomName: string, id: string, kind: 'client' | 'trackingClient' ) => {
|
||||||
|
if ( !rooms[roomName] ) return false;
|
||||||
|
|
||||||
|
const room = rooms[roomName][kind + 's' as 'clients' | 'trackingClients'] as Client[];
|
||||||
|
const idx = room.findIndex( client => client.id === id );
|
||||||
|
|
||||||
|
if ( idx < 0 )
|
||||||
|
return false;
|
||||||
|
|
||||||
|
room.splice( idx, 1 );
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close the room and disconnect all clients
|
||||||
|
* @param name - The name of the room
|
||||||
|
* @param uid - The name of the owner of the room
|
||||||
|
*/
|
||||||
|
const close = ( name: string, uid: string ) => {
|
||||||
|
if ( !rooms[name] || rooms[name].owner !== uid ) return false;
|
||||||
|
|
||||||
|
rooms[name].clients.forEach( client => client.response.send( 'data: closed\n\n' ) );
|
||||||
|
delete rooms[name];
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the state of the specified room
|
||||||
|
* @param room - The room to update the state for
|
||||||
|
* @param uid - The UserID of the user that created the room (i.e. the owner)
|
||||||
|
* @param playing - Set to true if the player is playing currently
|
||||||
|
* @param index - The current playback index
|
||||||
|
* @param start - The timestamp where playback of the current song started
|
||||||
|
* @param offset - The playback offset from the start in seconds
|
||||||
|
* @returns true if update succeeded
|
||||||
|
*/
|
||||||
|
const updateState = ( room: string, uid: string, playing: boolean, index: number, start: number, offset: number ) => {
|
||||||
|
if ( !rooms[room] || rooms[room].owner !== uid ) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
rooms[room].state = {
|
||||||
|
'playing': playing,
|
||||||
|
'index': index,
|
||||||
|
'lastUpdate': new Date().getTime(),
|
||||||
|
'start': start,
|
||||||
|
'offset': offset
|
||||||
|
};
|
||||||
|
|
||||||
|
return sendUpdate( room, 'state' );
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the playlist of the specified room
|
||||||
|
* @param room - The room to update the state for
|
||||||
|
* @param uid - The UserID of the user that created the room (i.e. the owner)
|
||||||
|
* @param playlist - The playlist that is currently playing
|
||||||
|
* @returns true if update succeeded
|
||||||
|
*/
|
||||||
|
const updatePlaylist = ( room: string, uid: string, playlist: PlaylistSongs ) => {
|
||||||
|
if ( !rooms[room] || rooms[room].owner !== uid ) return false;
|
||||||
|
|
||||||
|
rooms[room].playlist = {
|
||||||
|
'playlist': playlist,
|
||||||
|
'lastUpdate': new Date().getTime()
|
||||||
|
};
|
||||||
|
|
||||||
|
return sendUpdate( room, 'playlist' );
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendUpdate = ( room: string, kind: 'state' | 'playlist' ) => {
|
||||||
|
const roomObject = rooms[room];
|
||||||
|
|
||||||
|
if ( !roomObject ) return false;
|
||||||
|
|
||||||
|
roomObject.clients.forEach( client => client.response.write( `data: json:${ JSON.stringify( {
|
||||||
|
'type': kind,
|
||||||
|
'data': roomObject[kind]
|
||||||
|
} ) }\n\n` ) );
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an update from the trackking system to all admin clients
|
||||||
|
* @param room - The room to send it to
|
||||||
|
* @param update - The update to send
|
||||||
|
*/
|
||||||
|
const sendTrackingUpdate = ( room: string, update: string ) => {
|
||||||
|
if ( !rooms[room] ) return false;
|
||||||
|
|
||||||
|
rooms[room].trackingClients.forEach( client => client.response.write( `data: ${ update }\n\n` ) );
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
get,
|
||||||
|
create,
|
||||||
|
close,
|
||||||
|
updateState,
|
||||||
|
updatePlaylist,
|
||||||
|
sendTrackingUpdate,
|
||||||
|
addClient,
|
||||||
|
removeClient
|
||||||
|
};
|
||||||
|
|||||||
Vendored
+20
-13
@@ -1,24 +1,31 @@
|
|||||||
import {
|
import {
|
||||||
Playlist
|
PlaylistSongs
|
||||||
} from '../../dtype/file';
|
} from '../../dtype/file';
|
||||||
import {
|
import {
|
||||||
Response
|
Response
|
||||||
} from 'express';
|
} from 'express';
|
||||||
|
|
||||||
|
export interface Client {
|
||||||
|
'id': string;
|
||||||
|
'response': Response;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Room {
|
export interface Room {
|
||||||
'playlist': Playlist;
|
'playlist': {
|
||||||
'playing': boolean;
|
'lastUpdate': number;
|
||||||
'index': number;
|
'playlist': PlaylistSongs;
|
||||||
'start': number;
|
}
|
||||||
'token': string;
|
'state': {
|
||||||
'lastUpdate': number;
|
'index': number;
|
||||||
|
'start': number;
|
||||||
|
'playing': boolean;
|
||||||
|
'lastUpdate': number;
|
||||||
|
'offset': number;
|
||||||
|
};
|
||||||
|
'owner': string;
|
||||||
|
'clients': Client[];
|
||||||
|
'trackingClients': Client[];
|
||||||
|
|
||||||
/**
|
|
||||||
* This is used to determine if the playlist needs to be sent in polling mode
|
|
||||||
*/
|
|
||||||
'lastPlaylistUpdate': number;
|
|
||||||
|
|
||||||
'clients': Response[]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RoomStore {
|
export interface RoomStore {
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import {
|
||||||
|
Config
|
||||||
|
} from '../../dtype/config';
|
||||||
|
import express from 'express';
|
||||||
|
import {
|
||||||
|
generateToken
|
||||||
|
} from '../../token';
|
||||||
|
import rooms from '.';
|
||||||
|
|
||||||
|
export const sseMiddleware = ( kind: 'client' | 'trackingClient', config?: Config ) => {
|
||||||
|
return ( request: express.Request, response: express.Response ) => {
|
||||||
|
if ( typeof request.params.id !== 'string' )
|
||||||
|
return response.sendStatus( 400 );
|
||||||
|
|
||||||
|
const room = rooms.get( request.params.id );
|
||||||
|
|
||||||
|
if ( !room ) return response.sendStatus( 404 );
|
||||||
|
|
||||||
|
response.writeHead( 200, {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Connection': 'keep-alive'
|
||||||
|
} );
|
||||||
|
response.status( 200 );
|
||||||
|
response.flushHeaders();
|
||||||
|
|
||||||
|
if ( kind === 'trackingClient' || config?.clientMode === 'poll' )
|
||||||
|
response.write( 'data: use-poll\n\n' );
|
||||||
|
else
|
||||||
|
response.write( 'data: connected\n\n' );
|
||||||
|
|
||||||
|
const token = generateToken( 20 );
|
||||||
|
|
||||||
|
rooms.addClient( request.params.id, token, kind, response );
|
||||||
|
|
||||||
|
request.on( 'close', () => {
|
||||||
|
rooms.removeClient( request.params.id as string, token, kind );
|
||||||
|
} );
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import {
|
||||||
|
readFile,
|
||||||
|
stat,
|
||||||
|
writeFile
|
||||||
|
} from 'node:fs';
|
||||||
|
import {
|
||||||
|
UserPlaylistFile
|
||||||
|
} from '../../dtype/file';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
export const getUserFile = async ( uid: string ): Promise<UserPlaylistFile> => {
|
||||||
|
return new Promise( ( resolve, reject ) => {
|
||||||
|
readFile( getUserFilePath( uid ), ( err, data ) => {
|
||||||
|
if ( err ) return reject( err );
|
||||||
|
|
||||||
|
resolve( JSON.parse( String( data ) ) as UserPlaylistFile );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
};
|
||||||
|
|
||||||
|
const createUserFile = async ( uid: string ): Promise<void> => {
|
||||||
|
writeUserFile( uid, {
|
||||||
|
'playlists': [],
|
||||||
|
'userid': uid,
|
||||||
|
'version': '1'
|
||||||
|
} );
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserFilePath = ( uid: string ): string => {
|
||||||
|
return path.join( __dirname, '/../../../data/', uid + '.json' );
|
||||||
|
};
|
||||||
|
|
||||||
|
export const testUserFileExists = async ( uid: string ): Promise<boolean> => {
|
||||||
|
return new Promise( resolve => {
|
||||||
|
stat( getUserFilePath( uid ), err => {
|
||||||
|
if ( err ) return resolve( false );
|
||||||
|
|
||||||
|
resolve( true );
|
||||||
|
} );
|
||||||
|
} );
|
||||||
|
};
|
||||||
|
|
||||||
|
export const writeUserFile = async ( uid: string, file: UserPlaylistFile ) => {
|
||||||
|
writeFile( getUserFilePath( uid ), JSON.stringify( file ), err => {
|
||||||
|
if ( err ) return Promise.reject( err );
|
||||||
|
else Promise.resolve();
|
||||||
|
} );
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getUserFilePath,
|
||||||
|
getUserFile,
|
||||||
|
writeUserFile,
|
||||||
|
testUserFileExists,
|
||||||
|
createUserFile
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import {
|
||||||
|
getLoginSdk,
|
||||||
|
getStoreSdk
|
||||||
|
} from '../../sdk';
|
||||||
|
import express from 'express';
|
||||||
|
|
||||||
|
const cache: {
|
||||||
|
[key: string]: boolean
|
||||||
|
} = {};
|
||||||
|
|
||||||
|
export const getOwnershipManager = ( foss: boolean ) => {
|
||||||
|
const storeSdk = getStoreSdk( foss );
|
||||||
|
const sdk = getLoginSdk( foss );
|
||||||
|
|
||||||
|
const getOwned = async ( request: express.Request ) => {
|
||||||
|
const uid = sdk.getUID( request )!;
|
||||||
|
|
||||||
|
if ( cache[ uid ] ) {
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
const now = new Date().getTime();
|
||||||
|
|
||||||
|
if ( ( await storeSdk.getSubscriptions( uid ) ).findIndex(
|
||||||
|
sub => ( sub.id === 'com.janishutz.MusicPlayer.subscription' || sub.id === 'com.janishutz.MusicPlayer.subscription-month' ) && sub.expires - now > 0
|
||||||
|
) > -1 ) {
|
||||||
|
cache[ uid ] = true;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const middleware = () => {
|
||||||
|
return async ( request: express.Request, response: express.Response, next: express.NextFunction ) => {
|
||||||
|
if ( !await getOwned( request ) ) response.sendStatus( 402 );
|
||||||
|
else next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
getOwned,
|
||||||
|
middleware
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import cors from 'cors';
|
import corsManager from '../corsManager';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
import fs from 'fs';
|
import fs from 'fs';
|
||||||
|
import {
|
||||||
|
getLoginSdk
|
||||||
|
} from '../sdk';
|
||||||
|
import {
|
||||||
|
getOwnershipManager
|
||||||
|
} from '../manager/users/ownership';
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const routes = ( app: express.Application ) => {
|
const routes = ( app: express.Application, foss: boolean ) => {
|
||||||
|
const sdk = getLoginSdk( foss );
|
||||||
const privateKey = fs.readFileSync( path.join(
|
const privateKey = fs.readFileSync( path.join(
|
||||||
__dirname,
|
__dirname,
|
||||||
'/../config/apple_private_key.p8'
|
'/../config/apple_private_key.p8'
|
||||||
@@ -13,27 +20,31 @@ const routes = ( app: express.Application ) => {
|
|||||||
__dirname,
|
__dirname,
|
||||||
'/../config/apple-music-api.config.secret.json'
|
'/../config/apple-music-api.config.secret.json'
|
||||||
) ).toString() );
|
) ).toString() );
|
||||||
|
const ownership = getOwnershipManager( foss );
|
||||||
|
|
||||||
app.get( '/dev-token', cors( {
|
app.get(
|
||||||
'origin': 'http://localhost:8081',
|
'/dev-token',
|
||||||
'credentials': true
|
corsManager.middleware( false ),
|
||||||
} ), ( _request: express.Request, response: express.Response ) => {
|
sdk.loginCheck(),
|
||||||
// sign dev token
|
ownership.middleware(),
|
||||||
const now = new Date().getTime();
|
( _request: express.Request, response: express.Response ) => {
|
||||||
const tomorrow = now + ( 24 * 3600 * 1000 );
|
// sign dev token
|
||||||
const jwtToken = jwt.sign( {
|
const now = new Date().getTime();
|
||||||
'iss': musicApiConfig.teamID,
|
const tomorrow = now + ( 24 * 3600 * 1000 );
|
||||||
'iat': Math.floor( now / 1000 ),
|
const jwtToken = jwt.sign( {
|
||||||
'exp': Math.floor( tomorrow / 1000 )
|
'iss': musicApiConfig.teamID,
|
||||||
}, privateKey, {
|
'iat': Math.floor( now / 1000 ),
|
||||||
'algorithm': 'ES256',
|
'exp': Math.floor( tomorrow / 1000 )
|
||||||
'keyid': musicApiConfig.keyID
|
}, privateKey, {
|
||||||
} );
|
'algorithm': 'ES256',
|
||||||
|
'keyid': musicApiConfig.keyID
|
||||||
|
} );
|
||||||
|
|
||||||
response.send( jwtToken );
|
response.send( jwtToken );
|
||||||
} );
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
routes
|
routes
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,18 +1,50 @@
|
|||||||
|
import {
|
||||||
|
Config
|
||||||
|
} from '../../dtype/config';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import {
|
||||||
|
getLoginSdk
|
||||||
|
} from '../../sdk';
|
||||||
|
import {
|
||||||
|
getOwnershipManager
|
||||||
|
} from '../../manager/users/ownership';
|
||||||
|
import rooms from '../../manager/rooms';
|
||||||
import tracking from './tracking';
|
import tracking from './tracking';
|
||||||
import update from './update';
|
import update from './update';
|
||||||
|
|
||||||
const routes = ( app: express.Application ) => {
|
const routes = ( app: express.Application, foss: boolean, config: Config ) => {
|
||||||
tracking.routes( app );
|
const sdk = getLoginSdk( foss );
|
||||||
update.routes( app );
|
const ownership = getOwnershipManager( foss );
|
||||||
|
|
||||||
app.get( '/room/create', ( request: express.Request, response: express.Response ) => {
|
tracking.routes( app, foss );
|
||||||
|
update.routes( app, foss, config );
|
||||||
|
|
||||||
} );
|
app.get(
|
||||||
|
'/room/create',
|
||||||
|
sdk.loginCheck(),
|
||||||
|
ownership.middleware(),
|
||||||
|
( request: express.Request, response: express.Response ) => {
|
||||||
|
if ( !request.query.room || !( /^[a-zA-Z0-9-]{3,20}/ ).test( String( request.query.room ) ) ) return response.sendStatus( 400 );
|
||||||
|
|
||||||
app.get( '/room/:id/close', ( request: express.Request, response: express.Response ) => {
|
if ( rooms.create( String( request.query.room ), sdk.getUID( request )! ) )
|
||||||
|
response.sendStatus( 200 );
|
||||||
|
else
|
||||||
|
response.sendStatus( 409 );
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
} );
|
app.get(
|
||||||
|
'/room/:id/close',
|
||||||
|
sdk.loginCheck(),
|
||||||
|
( request: express.Request, response: express.Response ) => {
|
||||||
|
if ( typeof request.params.room !== 'string' ) return response.sendStatus( 400 );
|
||||||
|
|
||||||
|
if ( rooms.close( String( request.query.room ), sdk.getUID( request )! ) )
|
||||||
|
response.sendStatus( 200 );
|
||||||
|
else
|
||||||
|
response.sendStatus( 500 );
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -1,22 +1,46 @@
|
|||||||
|
import corsManager from '../../corsManager';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import {
|
||||||
|
getLoginSdk
|
||||||
|
} from '../../sdk';
|
||||||
|
import rooms from '../../manager/rooms';
|
||||||
|
import {
|
||||||
|
sseMiddleware
|
||||||
|
} from '../../manager/rooms/sse';
|
||||||
|
|
||||||
const routes = ( app: express.Application ) => {
|
const tackingUpdate = ( message: string ) => {
|
||||||
app.get( '/room/:id/tracking/exit', ( request: express.Request, response: express.Response ) => {
|
return ( request: express.Request, response: express.Response ) => {
|
||||||
|
if ( typeof request.params.id === 'string' ) {
|
||||||
|
if ( rooms.sendTrackingUpdate( request.params.id, message ) )
|
||||||
|
response.sendStatus( 200 );
|
||||||
|
else
|
||||||
|
response.sendStatus( 500 );
|
||||||
|
} else
|
||||||
|
response.sendStatus( 400 );
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
} );
|
const routes = ( app: express.Application, foss: boolean ) => {
|
||||||
|
const sdk = getLoginSdk( foss );
|
||||||
|
|
||||||
app.get( '/room/:id/tracking/ping', ( request: express.Request, response: express.Response ) => {
|
app.get(
|
||||||
|
'/room/:id/tracking/exit',
|
||||||
|
corsManager.middleware( false ),
|
||||||
|
tackingUpdate( 'exit' )
|
||||||
|
);
|
||||||
|
|
||||||
} );
|
app.get(
|
||||||
|
'/room/:id/tracking/ping',
|
||||||
|
corsManager.middleware( false ),
|
||||||
|
tackingUpdate( 'ping' )
|
||||||
|
);
|
||||||
|
|
||||||
app.get( '/room/:id/tracking/connect', ( request: express.Request, response: express.Response ) => {
|
app.get(
|
||||||
|
'/room/:id/admin',
|
||||||
} );
|
corsManager.middleware( false ),
|
||||||
|
sdk.loginCheck(),
|
||||||
app.get( '/room/:id/admin', ( request: express.Request, response: express.Response ) => {
|
sseMiddleware( 'trackingClient' )
|
||||||
// Endpoint for the streamer to connect to, all above events are sent there
|
);
|
||||||
|
|
||||||
} );
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -1,21 +1,96 @@
|
|||||||
|
import {
|
||||||
|
Config
|
||||||
|
} from '../../dtype/config';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
|
import corsManager from '../../corsManager';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import {
|
||||||
|
getLoginSdk
|
||||||
|
} from '../../sdk';
|
||||||
|
import logger from '../../logger';
|
||||||
|
import rooms from '../../manager/rooms';
|
||||||
|
import {
|
||||||
|
sseMiddleware
|
||||||
|
} from '../../manager/rooms/sse';
|
||||||
|
|
||||||
const routes = ( app: express.Application ) => {
|
const routes = ( app: express.Application, foss: boolean, config: Config ) => {
|
||||||
app.get( '/room/:id/connect', ( request: express.Request, response: express.Response ) => {
|
const sdk = getLoginSdk( foss );
|
||||||
|
|
||||||
|
app.get( '/room/:id/connect', corsManager.middleware( false ), sseMiddleware( 'client', config ) );
|
||||||
|
|
||||||
|
app.get( '/room/:id/poll', corsManager.middleware( false ), ( request: express.Request, response: express.Response ) => {
|
||||||
|
if ( typeof request.params.id !== 'string' )
|
||||||
|
return response.sendStatus( 400 );
|
||||||
|
|
||||||
|
const room = rooms.get( request.params.id );
|
||||||
|
|
||||||
|
if ( !room ) response.sendStatus( 404 );
|
||||||
|
|
||||||
|
const lastRequest = parseInt( String( request.query.last ) ) ?? 0;
|
||||||
|
|
||||||
|
response.send(
|
||||||
|
JSON.stringify( {
|
||||||
|
'state': room?.state,
|
||||||
|
'playlist': ( lastRequest < room!.playlist!.lastUpdate || isNaN( lastRequest ) ) ? room!.playlist.playlist : undefined
|
||||||
|
} )
|
||||||
|
);
|
||||||
} );
|
} );
|
||||||
|
|
||||||
app.get( '/room/:id/poll', ( request: express.Request, response: express.Response ) => {
|
app.options( '/room/:id/update/playlist', corsManager.middleware( false ) );
|
||||||
|
app.post(
|
||||||
|
'/room/:id/update/playlist',
|
||||||
|
corsManager.middleware( false ),
|
||||||
|
sdk.loginCheck(),
|
||||||
|
bodyParser.json(),
|
||||||
|
( request: express.Request, response: express.Response ) => {
|
||||||
|
if ( typeof request.params.id !== 'string' )
|
||||||
|
return response.sendStatus( 400 );
|
||||||
|
|
||||||
} );
|
try {
|
||||||
|
if ( rooms.updatePlaylist(
|
||||||
|
request.params.id,
|
||||||
|
sdk.getUID( request )!,
|
||||||
|
request.body.playlist ?? []
|
||||||
|
) )
|
||||||
|
response.sendStatus( 200 );
|
||||||
|
else
|
||||||
|
response.sendStatus( 400 );
|
||||||
|
} catch ( err ) {
|
||||||
|
logger.error( 'Failed sending updated playlists with error', err );
|
||||||
|
response.sendStatus( 500 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
app.get( '/room/:id/update/playlist', ( request: express.Request, response: express.Response ) => {
|
app.options( '/room/:id/update/state', corsManager.middleware( false ) );
|
||||||
|
app.post(
|
||||||
|
'/room/:id/update/state',
|
||||||
|
corsManager.middleware( false ),
|
||||||
|
sdk.loginCheck(),
|
||||||
|
bodyParser.json(),
|
||||||
|
( request: express.Request, response: express.Response ) => {
|
||||||
|
if ( typeof request.params.id !== 'string' ) {
|
||||||
|
return response.sendStatus( 400 );
|
||||||
|
}
|
||||||
|
|
||||||
} );
|
try {
|
||||||
|
if ( rooms.updateState(
|
||||||
app.get( '/room/:id/update/state', ( request: express.Request, response: express.Response ) => {
|
request.params.id,
|
||||||
|
sdk.getUID( request )!,
|
||||||
} );
|
request.body.playing ?? false,
|
||||||
|
request.body.index ?? -1,
|
||||||
|
request.body.start ?? new Date().getTime(),
|
||||||
|
request.body.offset ?? 0
|
||||||
|
) )
|
||||||
|
response.sendStatus( 200 );
|
||||||
|
else
|
||||||
|
response.sendStatus( 400 );
|
||||||
|
} catch ( err ) {
|
||||||
|
logger.error( 'Failed sending updated state with error', err );
|
||||||
|
response.sendStatus( 500 );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
|||||||
@@ -1,11 +1,73 @@
|
|||||||
|
import {
|
||||||
|
Playlist,
|
||||||
|
UserPlaylistFile
|
||||||
|
} from '../../dtype/file';
|
||||||
|
import {
|
||||||
|
getUserFile,
|
||||||
|
writeUserFile
|
||||||
|
} from '../../manager/users';
|
||||||
|
import bodyParser from 'body-parser';
|
||||||
import express from 'express';
|
import express from 'express';
|
||||||
|
import {
|
||||||
|
getLoginSdk
|
||||||
|
} from '../../sdk';
|
||||||
|
import {
|
||||||
|
getOwnershipManager
|
||||||
|
} from '../../manager/users/ownership';
|
||||||
|
import logger from '../../logger';
|
||||||
|
|
||||||
const routes = ( app: express.Application ) => {
|
const routes = ( app: express.Application, foss: boolean ) => {
|
||||||
app.get( '/user/playlists', ( request: express.Request, response: express.Response ) => {
|
const sdk = getLoginSdk( foss );
|
||||||
|
const ownership = getOwnershipManager( foss );
|
||||||
|
|
||||||
|
app.get( '/user/owned', sdk.loginCheck(), async ( request: express.Request, response: express.Response ) => {
|
||||||
|
if ( await ownership.getOwned( request ) )
|
||||||
|
response.send( true );
|
||||||
|
else
|
||||||
|
response.send( true );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
app.post( '/user/playlists', ( request: express.Request, response: express.Response ) => {
|
app.get(
|
||||||
|
'/user/playlists',
|
||||||
|
sdk.loginCheck(),
|
||||||
|
async ( request: express.Request, response: express.Response ) => {
|
||||||
|
try {
|
||||||
|
const playlists = await getUserFile( sdk.getUID( request )! );
|
||||||
|
|
||||||
} );
|
response.send( JSON.stringify( playlists.playlists ) );
|
||||||
|
} catch ( e ) {
|
||||||
|
logger.error( 'Failed retrieving user file with error', e );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
app.post(
|
||||||
|
'/user/playlists',
|
||||||
|
sdk.loginCheck(),
|
||||||
|
ownership.middleware(),
|
||||||
|
bodyParser.json(),
|
||||||
|
async ( request: express.Request, response: express.Response ) => {
|
||||||
|
try {
|
||||||
|
const contents: UserPlaylistFile = {
|
||||||
|
'playlists': request.body as Playlist[],
|
||||||
|
'userid': sdk.getUID( request )!,
|
||||||
|
'version': '1'
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await writeUserFile( contents.userid, contents );
|
||||||
|
response.sendStatus( 200 );
|
||||||
|
} catch ( e ) {
|
||||||
|
logger.error( 'Failed updating user playlists', e );
|
||||||
|
response.sendStatus( 500 );
|
||||||
|
}
|
||||||
|
} catch ( e ) {
|
||||||
|
logger.error( 'Failed retrieving user file with error', e );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
routes
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,16 +5,24 @@ interface StoreSDKConfig {
|
|||||||
'loglevel': 'debug' | 'info' | 'log' | 'warn' | 'error' | 'none';
|
'loglevel': 'debug' | 'info' | 'log' | 'warn' | 'error' | 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SubscriptionData {
|
||||||
|
'id': string;
|
||||||
|
'status': string;
|
||||||
|
'expires': number;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
const getSubscriptions = ( _uid: string ) => {
|
const getSubscriptions = async ( _uid: string ): Promise<SubscriptionData[]> => {
|
||||||
return [ {
|
return [ {
|
||||||
'id': 'com.janishutz.MusicPlayer.subscription',
|
'id': 'com.janishutz.MusicPlayer.subscription',
|
||||||
'expires': new Date().getTime() + 200000,
|
'expires': new Date().getTime() + 200000,
|
||||||
'status': true
|
'status': 'active'
|
||||||
} ];
|
} ];
|
||||||
};
|
};
|
||||||
|
|
||||||
const configure = ( _config: StoreSDKConfig ) => {}
|
const configure = ( config: StoreSDKConfig ) => {
|
||||||
|
console.log( config );
|
||||||
|
};
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
getSubscriptions,
|
getSubscriptions,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import crypto from 'node:crypto';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a token for e.g. login, etc
|
||||||
|
* @param length - The number of characters of the token
|
||||||
|
* @returns The generated token
|
||||||
|
*/
|
||||||
|
export const generateToken = ( length: number ): string => {
|
||||||
|
let token = '';
|
||||||
|
|
||||||
|
const min = 45;
|
||||||
|
const max = 122;
|
||||||
|
|
||||||
|
for ( let i = 0; i < length; i++ ) {
|
||||||
|
const buf = new Uint8Array( 1 );
|
||||||
|
|
||||||
|
crypto.getRandomValues( buf );
|
||||||
|
let randomNumber = Math.floor( ( buf[ 0 ] / 256 * ( max - min ) ) + min );
|
||||||
|
|
||||||
|
while (
|
||||||
|
( 58 < randomNumber && randomNumber < 63 )
|
||||||
|
|| ( 90 < randomNumber && randomNumber < 95 )
|
||||||
|
|| ( 95 < randomNumber && randomNumber < 97 )
|
||||||
|
) {
|
||||||
|
const buf = new Uint8Array( 1 );
|
||||||
|
|
||||||
|
crypto.getRandomValues( buf );
|
||||||
|
randomNumber = Math.floor( ( buf[ 0 ] / 256 * ( max - min ) ) + min );
|
||||||
|
}
|
||||||
|
|
||||||
|
token += String.fromCharCode( randomNumber );
|
||||||
|
}
|
||||||
|
|
||||||
|
return token;
|
||||||
|
};
|
||||||
+24
-1
@@ -1,4 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
Notifications
|
||||||
|
} from '@kyvg/vue3-notification';
|
||||||
import {
|
import {
|
||||||
RouterView
|
RouterView
|
||||||
} from 'vue-router';
|
} from 'vue-router';
|
||||||
@@ -35,6 +38,14 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
|
<notifications
|
||||||
|
position="top center"
|
||||||
|
:duration="5000"
|
||||||
|
class="notifications"
|
||||||
|
style="top: 20px;"
|
||||||
|
width="400px"
|
||||||
|
:max="3"
|
||||||
|
/>
|
||||||
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
|
<button id="themeSelector" title="Toggle between light and dark mode" @click="changeTheme();">
|
||||||
<i :class="['fa-solid', 'fa-' + theme]"></i>
|
<i :class="['fa-solid', 'fa-' + theme]"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -46,11 +57,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style lang="scss">
|
||||||
body {
|
body {
|
||||||
background-color: var( --background-color );
|
background-color: var( --background-color );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notifications {
|
||||||
|
.vue-notification {
|
||||||
|
padding: 20px;
|
||||||
|
.notification-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
.notification-text {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:root, :root.light {
|
:root, :root.light {
|
||||||
--primary-color: #0a1520;
|
--primary-color: #0a1520;
|
||||||
--secondary-color: white;
|
--secondary-color: white;
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div
|
<div
|
||||||
class="panel"
|
class="panel"
|
||||||
>
|
>
|
||||||
<div style="margin-bottom: 20px; width: 100%;">
|
<div class="current-song-wrapper">
|
||||||
<CurrentSong
|
<CurrentSong
|
||||||
v-model="queue[queueIdx]"
|
v-model="queue[queueIdx]"
|
||||||
/>
|
/>
|
||||||
@@ -44,6 +44,15 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
>.current-song-wrapper {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: calc(100% - 15rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import AssociationView from '@/composables/AssociationView.vue';
|
||||||
import PlayerComponent from './PlayerComponent.vue';
|
import PlayerComponent from './PlayerComponent.vue';
|
||||||
import SmallPlayerComponent from './SmallPlayerComponent.vue';
|
import SmallPlayerComponent from './SmallPlayerComponent.vue';
|
||||||
import {
|
import {
|
||||||
ref
|
fullPlayer
|
||||||
} from 'vue';
|
} from '@/ts/player/state';
|
||||||
|
|
||||||
const fullPlayer = ref( true );
|
|
||||||
|
|
||||||
const close = () => {
|
const close = () => {
|
||||||
fullPlayer.value = false;
|
fullPlayer.value = false;
|
||||||
@@ -14,6 +13,7 @@
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="player-wrapper">
|
<div class="player-wrapper">
|
||||||
|
<AssociationView />
|
||||||
<SmallPlayerComponent v-model="fullPlayer" />
|
<SmallPlayerComponent v-model="fullPlayer" />
|
||||||
<div :class="['player-container', fullPlayer ? undefined : 'hidden']">
|
<div :class="['player-container', fullPlayer ? undefined : 'hidden']">
|
||||||
<i class="fa-solid fa-xmark" @click="close"></i>
|
<i class="fa-solid fa-xmark" @click="close"></i>
|
||||||
@@ -32,6 +32,7 @@
|
|||||||
background-color: var(--secondary-color);
|
background-color: var(--secondary-color);
|
||||||
transition: bottom 1s ease;
|
transition: bottom 1s ease;
|
||||||
transition-delay: 0.25s;
|
transition-delay: 0.25s;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
transition-delay: 0s;
|
transition-delay: 0s;
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import UserPlaylists from '../playlists/UserPlaylists.vue';
|
||||||
|
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="playlist-main">
|
||||||
<h1>Playlists</h1>
|
<h1>Playlists</h1>
|
||||||
|
<UserPlaylists />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.playlist-main {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -1,4 +1,24 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type ComputedRef,
|
||||||
|
computed
|
||||||
|
} from 'vue';
|
||||||
|
import {
|
||||||
|
isPlaying,
|
||||||
|
queue,
|
||||||
|
queueIdx
|
||||||
|
} from '@/ts/player/state';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
import {
|
||||||
|
beautifyTime
|
||||||
|
} from '@/ts/util/time';
|
||||||
|
import {
|
||||||
|
playbackPercentage
|
||||||
|
} from '@/ts/player/status-tracking';
|
||||||
|
import player from '@/ts/player';
|
||||||
|
|
||||||
const closed = defineModel<boolean>( {
|
const closed = defineModel<boolean>( {
|
||||||
'required': true
|
'required': true
|
||||||
} );
|
} );
|
||||||
@@ -6,30 +26,117 @@
|
|||||||
const open = () => {
|
const open = () => {
|
||||||
closed.value = true;
|
closed.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const song: ComputedRef<Song> = computed( () => {
|
||||||
|
if ( queueIdx.value >= 0 && queue.value.length > queueIdx.value ) {
|
||||||
|
return queue.value[ queueIdx.value ]!;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
'artwork': '',
|
||||||
|
'additional-info': '',
|
||||||
|
'artist': 'No artist',
|
||||||
|
'duration': -1,
|
||||||
|
'identifier': 'nosong-ident',
|
||||||
|
'name': 'Not Playing',
|
||||||
|
'source': 'local'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} );
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="['small-player', closed ? 'hidden' : undefined]" @click="open">
|
<div :class="['small-player', closed ? 'hidden' : undefined]">
|
||||||
Player
|
<div>
|
||||||
|
<img
|
||||||
|
v-if="song.artwork"
|
||||||
|
:src="song.artwork"
|
||||||
|
alt="Song cover"
|
||||||
|
class="song-cover"
|
||||||
|
>
|
||||||
|
<i v-else class="fa-solid fa-music song-cover" @click="open"></i>
|
||||||
|
<div @click="open">
|
||||||
|
<h3>{{ song.name }}</h3>
|
||||||
|
<p>{{ song.artist }}</p>
|
||||||
|
</div>
|
||||||
|
<p @click="open">
|
||||||
|
{{ beautifyTime( playbackPercentage * song.duration ) }} / {{ beautifyTime( song.duration ) }}
|
||||||
|
</p>
|
||||||
|
<i v-if="!isPlaying" class="fa-solid fa-play" @click="player.play"></i>
|
||||||
|
<i v-else class="fa-solid fa-pause" @click="player.pause"></i>
|
||||||
|
<i class="fa-solid fa-forward-step" @click="player.next"></i>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.small-player {
|
.small-player {
|
||||||
background-color: green;
|
background-color: var(--secondary-color);
|
||||||
width: 80vw;
|
width: 80vw;
|
||||||
height: 7vh;
|
height: 4.5rem;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 10vw;
|
left: 10vw;
|
||||||
bottom: 15px;
|
bottom: 15px;
|
||||||
border-radius: 4vh;
|
border-radius: 2rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: bottom 0.5s ease;
|
transition: bottom 0.5s ease;
|
||||||
transition-delay: 0.75s;
|
transition-delay: 0.75s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
&.hidden {
|
&.hidden {
|
||||||
bottom: -8vh;
|
bottom: -8vh;
|
||||||
transition-delay: 0s;
|
transition-delay: 0s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
>div {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: calc(100% - 6rem);
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
>.song-cover {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
width: 3rem;
|
||||||
|
height: 3rem;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>div {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: center;
|
||||||
|
flex-direction: column;
|
||||||
|
width: calc(100% - 20rem);
|
||||||
|
overflow-x: hidden;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
>h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
>p {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>p {
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 15px;
|
||||||
|
width: max-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.fa-solid {
|
||||||
|
font-size: 2rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
const song = defineModel<Song>( {
|
const song = defineModel<Song>( {
|
||||||
'required': false
|
'required': false
|
||||||
} );
|
} );
|
||||||
|
const props = defineProps<{
|
||||||
|
'showAdditionalInfo'?: boolean
|
||||||
|
}>();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -24,6 +27,9 @@
|
|||||||
{{ song?.name ?? 'Not playing' }}
|
{{ song?.name ?? 'Not playing' }}
|
||||||
</h1>
|
</h1>
|
||||||
<p>{{ song?.artist ?? 'No artist' }}</p>
|
<p>{{ song?.artist ?? 'No artist' }}</p>
|
||||||
|
<p v-if="props.showAdditionalInfo">
|
||||||
|
{{ song?.['additional-info'] }}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -31,14 +37,21 @@
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.current-song {
|
.current-song {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
.artwork {
|
.artwork {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: calc(100% - 9rem);
|
||||||
|
|
||||||
>img, .fa-solid {
|
>img, .fa-solid {
|
||||||
width: 60%;
|
height: 100%;
|
||||||
max-height: 50%;
|
font-size: 40vh;
|
||||||
font-size: 15vw;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
computed,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
import ProgressBar from './ProgressBar.vue';
|
import ProgressBar from './ProgressBar.vue';
|
||||||
|
import ShareManagement from './ShareManagement.vue';
|
||||||
import {
|
import {
|
||||||
beautifyTime
|
beautifyTime
|
||||||
} from '@/ts/util/time';
|
} from '@/ts/util/time';
|
||||||
import {
|
import messages from '@/ts/messages';
|
||||||
computed
|
|
||||||
} from 'vue';
|
|
||||||
import player from '@/ts/player';
|
import player from '@/ts/player';
|
||||||
|
|
||||||
const playbackPercentage = player.playbackPercentage;
|
const playbackPercentage = player.playbackPercentage;
|
||||||
const repeatMode = player.repeat;
|
const repeatMode = player.repeat;
|
||||||
const shuffleMode = player.shuffle;
|
const shuffleMode = player.shuffle;
|
||||||
|
const showShareMenu = ref( false );
|
||||||
|
|
||||||
const seek = () => {
|
const seek = () => {
|
||||||
player.seekTo( playbackPercentage.value );
|
player.seekTo( playbackPercentage.value );
|
||||||
@@ -30,7 +34,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
const openShareMenu = () => {
|
const openShareMenu = () => {
|
||||||
alert( 'Share menu not yet implemented' );
|
showShareMenu.value = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
const current = computed( () => {
|
const current = computed( () => {
|
||||||
@@ -40,12 +44,12 @@
|
|||||||
return beautifyTime( player.duration.value );
|
return beautifyTime( player.duration.value );
|
||||||
} );
|
} );
|
||||||
|
|
||||||
|
messages.useRoomWatchers();
|
||||||
// TODO: Button availability
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="mp-player">
|
<div class="mp-player">
|
||||||
|
<ShareManagement v-model="showShareMenu" />
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<i class="fa-solid fa-backward-step" @click="player.prev"></i>
|
<i class="fa-solid fa-backward-step" @click="player.prev"></i>
|
||||||
<i class="fa-solid fa-arrow-rotate-left quick-seek" @click="player.back10"></i>
|
<i class="fa-solid fa-arrow-rotate-left quick-seek" @click="player.back10"></i>
|
||||||
|
|||||||
@@ -8,26 +8,33 @@
|
|||||||
'required': true,
|
'required': true,
|
||||||
'default': 0.5
|
'default': 0.5
|
||||||
} );
|
} );
|
||||||
|
const moveVal = ref( 0 );
|
||||||
const offset = ref( -1 );
|
const offset = ref( -1 );
|
||||||
const isMoving = ref( false );
|
const isMoving = ref( false );
|
||||||
const bar = useTemplateRef( 'bar' );
|
const bar = useTemplateRef( 'bar' );
|
||||||
|
const props = defineProps<{
|
||||||
|
'disallowMove'?: boolean
|
||||||
|
}>();
|
||||||
|
|
||||||
const start = ( ev: MouseEvent ) => {
|
const start = ( ev: MouseEvent ) => {
|
||||||
|
if ( props.disallowMove ) return;
|
||||||
|
|
||||||
offset.value = bar.value!.getBoundingClientRect().x;
|
offset.value = bar.value!.getBoundingClientRect().x;
|
||||||
isMoving.value = true;
|
isMoving.value = true;
|
||||||
val.value = ( ev.x - offset.value ) / bar.value!.clientWidth;
|
moveVal.value = ( ev.x - offset.value ) / bar.value!.clientWidth;
|
||||||
emit( 'move-start' );
|
emit( 'move-start' );
|
||||||
};
|
};
|
||||||
|
|
||||||
const move = ( ev: MouseEvent ) => {
|
const move = ( ev: MouseEvent ) => {
|
||||||
if ( isMoving.value ) {
|
if ( isMoving.value ) {
|
||||||
val.value = Math.max( 0, Math.min( ( ev.x - offset.value ) / bar.value!.clientWidth, 1 ) );
|
moveVal.value = Math.max( 0, Math.min( ( ev.x - offset.value ) / bar.value!.clientWidth, 1 ) );
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const end = () => {
|
const end = ( ev: MouseEvent ) => {
|
||||||
if ( !isMoving.value ) return;
|
if ( !isMoving.value ) return;
|
||||||
|
|
||||||
|
val.value = ( ev.x - offset.value ) / bar.value!.clientWidth;
|
||||||
offset.value = -1;
|
offset.value = -1;
|
||||||
isMoving.value = false;
|
isMoving.value = false;
|
||||||
emit( 'move-end' );
|
emit( 'move-end' );
|
||||||
@@ -42,10 +49,10 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="progressbar">
|
<div class="progressbar">
|
||||||
<div ref="bar" class="back">
|
<div ref="bar" class="back">
|
||||||
<div :style="`width: ${ val * 100 }%;`"></div>
|
<div :style="`width: ${ isMoving ? moveVal * 100 : val * 100 }%;`"></div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:class="['click-target', offset >= 0 ? 'active' : undefined]"
|
:class="['click-target', offset >= 0 ? 'active' : undefined, props.disallowMove ? 'disallowed' : undefined]"
|
||||||
@mousedown="start"
|
@mousedown="start"
|
||||||
@mousemove="move"
|
@mousemove="move"
|
||||||
@mouseup="end"
|
@mouseup="end"
|
||||||
|
|||||||
@@ -18,6 +18,9 @@
|
|||||||
import {
|
import {
|
||||||
queueIdx
|
queueIdx
|
||||||
} from '@/ts/player/state';
|
} from '@/ts/player/state';
|
||||||
|
import {
|
||||||
|
savePlaylist
|
||||||
|
} from '@/ts/userPlaylists/save';
|
||||||
|
|
||||||
const queue: WritableComputedRef<Song[]> = computed( {
|
const queue: WritableComputedRef<Song[]> = computed( {
|
||||||
get () {
|
get () {
|
||||||
@@ -56,18 +59,16 @@
|
|||||||
<i class="fa-solid fa-xmark"></i>
|
<i class="fa-solid fa-xmark"></i>
|
||||||
Clear
|
Clear
|
||||||
</button>
|
</button>
|
||||||
<!-- TODO: On save, copy the current queue into rawQueue! -->
|
<button @click="editSong( -1 )">
|
||||||
<button>
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
|
Edit Current
|
||||||
|
</button>
|
||||||
|
<button @click="savePlaylist">
|
||||||
<i class="fa-solid fa-save"></i>
|
<i class="fa-solid fa-save"></i>
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
<!-- TODO: Should only appear when sharing -->
|
|
||||||
<button>
|
|
||||||
<i class="fa-solid fa-paper-plane"></i>
|
|
||||||
Transmit
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
<SongEditor v-model="showEditSong" editing-song="" />
|
<SongEditor v-model="showEditSong" :song="editingSong" />
|
||||||
<AddSong v-model="showAddSong" />
|
<AddSong v-model="showAddSong" />
|
||||||
<div v-if="queue.length > 0" class="queue-container">
|
<div v-if="queue.length > 0" class="queue-container">
|
||||||
<SortableList v-slot="{ item: song, index }" v-model="queue">
|
<SortableList v-slot="{ item: song, index }" v-model="queue">
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import messages, {
|
||||||
|
isConnected
|
||||||
|
} from '@/ts/messages';
|
||||||
|
import PopupElement from '../popups/PopupElement.vue';
|
||||||
|
import {
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
const showPopup = defineModel<boolean>( {
|
||||||
|
'required': true
|
||||||
|
} );
|
||||||
|
const shareName = ref( '' );
|
||||||
|
const useAntiTamper = ref( false );
|
||||||
|
const errorMessage = ref( '' );
|
||||||
|
|
||||||
|
const startShare = async () => {
|
||||||
|
if ( !await messages.createRoom( shareName.value, useAntiTamper.value ) ) {
|
||||||
|
errorMessage.value = 'Invalid room name';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopShare = () => {
|
||||||
|
messages.closeRoom();
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PopupElement v-model="showPopup" show-close>
|
||||||
|
<h2>Share</h2>
|
||||||
|
<div v-if="!isConnected">
|
||||||
|
<p>
|
||||||
|
You can use a share to show what you are currently listening to (and the progress) on a page.
|
||||||
|
</p>
|
||||||
|
<p>{{ errorMessage }}</p>
|
||||||
|
<input v-model="shareName" type="text">
|
||||||
|
<button @click="startShare">
|
||||||
|
Create Share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else>
|
||||||
|
<!-- TODO: Need to explain and add controls -->
|
||||||
|
<!-- TODO: How to handle anti-tamper? -->
|
||||||
|
<p>Connected</p>
|
||||||
|
<button @click="stopShare">
|
||||||
|
End share
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PopupElement from '../popups/PopupElement.vue';
|
||||||
|
import {
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
const showPopup = defineModel<boolean>( {
|
||||||
|
'required': true
|
||||||
|
} );
|
||||||
|
|
||||||
|
const addPlaylist = () => {
|
||||||
|
showPopup.value = false;
|
||||||
|
emit( 'add-playlist', playlistName.value );
|
||||||
|
};
|
||||||
|
|
||||||
|
const playlistName = ref( '' );
|
||||||
|
const emit = defineEmits<{
|
||||||
|
( e: 'add-playlist', name: string ): void;
|
||||||
|
}>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PopupElement v-model="showPopup" show-close>
|
||||||
|
<div class="title">
|
||||||
|
<h1>Add Playlist</h1>
|
||||||
|
</div>
|
||||||
|
<input v-model="playlistName" type="text" placeholder="Playlist name">
|
||||||
|
<br>
|
||||||
|
<button @click="addPlaylist">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
Add Playlist
|
||||||
|
</button>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.title {
|
||||||
|
>h1 {
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
>p {
|
||||||
|
margin: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.song-sources {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
width: 50vw;
|
||||||
|
height: 40vh;
|
||||||
|
justify-content: center;
|
||||||
|
overflow-y: scroll;
|
||||||
|
overflow-x: hidden;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
width: 45%;
|
||||||
|
margin: 0.5%;
|
||||||
|
height: 60%;
|
||||||
|
background-color: var(--accent-background);
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
>div {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
>.fa-solid {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
>.not-auth-notice {
|
||||||
|
font-size: 0.6rem;
|
||||||
|
margin: 0;
|
||||||
|
width: 70%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
addPlaylist,
|
||||||
|
removePlaylist,
|
||||||
|
selectPlaylist
|
||||||
|
} from '@/ts/userPlaylists';
|
||||||
|
import {
|
||||||
|
editingPlaylists,
|
||||||
|
playlists
|
||||||
|
} from '@/ts/userPlaylists/state';
|
||||||
|
import {
|
||||||
|
getPlaylists,
|
||||||
|
savePlaylists
|
||||||
|
} from '@/ts/userPlaylists/save';
|
||||||
|
import {
|
||||||
|
onMounted,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
import AddPlaylist from './AddPlaylist.vue';
|
||||||
|
import {
|
||||||
|
UnownedError
|
||||||
|
} from '@/ts/request';
|
||||||
|
import router from '@/router';
|
||||||
|
|
||||||
|
const checkingStatus = ref( true );
|
||||||
|
const dots = ref( 0 );
|
||||||
|
const showAddPlaylist = ref( false );
|
||||||
|
|
||||||
|
let interval = -1;
|
||||||
|
|
||||||
|
const loadPlaylists = async () => {
|
||||||
|
if ( interval < 0 ) {
|
||||||
|
interval = setInterval( () => {
|
||||||
|
dots.value = ( dots.value + 1 ) % 4;
|
||||||
|
}, 500 );
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await getPlaylists();
|
||||||
|
} catch ( e ) {
|
||||||
|
if ( e instanceof UnownedError ) {
|
||||||
|
router.push( '/get' );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkingStatus.value = false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
clearInterval( interval );
|
||||||
|
interval = -1;
|
||||||
|
} catch { /* Empty */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted( () => {
|
||||||
|
loadPlaylists();
|
||||||
|
} );
|
||||||
|
|
||||||
|
onMounted( () => {} );
|
||||||
|
|
||||||
|
const openAddPlaylistPopup = () => {
|
||||||
|
showAddPlaylist.value = true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePlaylistEditing = ( idx: number ) => {
|
||||||
|
editingPlaylists.value[ idx ] = !editingPlaylists.value[ idx ];
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="playlists">
|
||||||
|
<AddPlaylist v-model="showAddPlaylist" @add-playlist="addPlaylist" />
|
||||||
|
<div v-if="checkingStatus" class="playlist-wrapper">
|
||||||
|
Loading{{ '.'.repeat( dots ) }}
|
||||||
|
</div>
|
||||||
|
<div v-else-if="playlists.length === 0" class="playlist-wrapper">
|
||||||
|
No playlists
|
||||||
|
<button @click="openAddPlaylistPopup">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
Add one
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div v-else class="playlist-wrapper">
|
||||||
|
<div class="playlist-actions">
|
||||||
|
<button @click="openAddPlaylistPopup">
|
||||||
|
<i class="fa-solid fa-plus"></i>
|
||||||
|
Add Playlist
|
||||||
|
</button>
|
||||||
|
<button @click="savePlaylists">
|
||||||
|
<i class="fa-solid fa-floppy-disk"></i>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
<button @click="loadPlaylists">
|
||||||
|
<i class="fa-solid fa-rotate"></i>
|
||||||
|
Undo unsaved changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-container">
|
||||||
|
<div v-for="(playlist, index) in playlists" :key="index" class="playlist">
|
||||||
|
<i class="fa-solid fa-circle-play" @click="() => selectPlaylist( index )"></i>
|
||||||
|
<input v-if="editingPlaylists[ index ]" v-model="playlist.name" type="text">
|
||||||
|
<h2 v-else @click="() => selectPlaylist( index )">
|
||||||
|
{{ playlist.name }}
|
||||||
|
</h2>
|
||||||
|
<i class="fa-solid fa-pen-to-square" @click="() => togglePlaylistEditing( index )"></i>
|
||||||
|
<i class="fa-solid fa-trash" @click="() => removePlaylist( index )"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/scss/components/playlists.scss';
|
||||||
|
</style>
|
||||||
@@ -1,16 +1,76 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type Ref, ref,
|
||||||
|
watch
|
||||||
|
} from 'vue';
|
||||||
import PopupElement from './PopupElement.vue';
|
import PopupElement from './PopupElement.vue';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
import player from '@/ts/player';
|
||||||
|
|
||||||
const model = defineModel<boolean>( {
|
const model = defineModel<boolean>( {
|
||||||
'required': true
|
'required': true
|
||||||
} );
|
} );
|
||||||
|
const props = defineProps<{
|
||||||
|
'song': Song | null
|
||||||
|
}>();
|
||||||
|
const localSong: Ref<Song> = ref( {
|
||||||
|
'name': '',
|
||||||
|
'artist': '',
|
||||||
|
'artwork': '',
|
||||||
|
'additional-info': '',
|
||||||
|
'duration': -1,
|
||||||
|
'identifier': '',
|
||||||
|
'source': 'local'
|
||||||
|
} );
|
||||||
|
|
||||||
|
watch( props, () => {
|
||||||
|
if ( props.song )
|
||||||
|
localSong.value = props.song;
|
||||||
|
} );
|
||||||
|
|
||||||
|
const search = () => {
|
||||||
|
player.addSongFromSource( 'applemusic', ( songs: Song[] ) => {
|
||||||
|
const song = songs[ 0 ];
|
||||||
|
|
||||||
|
if ( !song ) return;
|
||||||
|
|
||||||
|
localSong.value.artist = song.artist;
|
||||||
|
localSong.value.artwork = song.artwork;
|
||||||
|
localSong.value.name = song.name;
|
||||||
|
}, true );
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = () => {
|
||||||
|
model.value = false;
|
||||||
|
document.dispatchEvent( new CustomEvent( 'musicplayer:update' ) );
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<PopupElement v-model="model" show-close>
|
<PopupElement v-model="model" show-close>
|
||||||
<h2>Edit Song</h2>
|
<h2>Edit Song</h2>
|
||||||
<!-- TODO: Need a way to use the search feature to replace the song details, as of course as well text editors -->
|
<button @click="search">
|
||||||
|
Search song on Apple Music
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<label for="song-name">Song title</label>
|
||||||
|
<input id="song-name" v-model="localSong.name" type="text">
|
||||||
|
|
||||||
|
<label for="song-artist">Artist</label>
|
||||||
|
<input id="song-artist" v-model="localSong.artist" type="text">
|
||||||
|
|
||||||
|
<label for="song-artwork">Artwork URL</label>
|
||||||
|
<input id="song-artwork" v-model="localSong.artwork" type="text">
|
||||||
|
|
||||||
|
<label for="song-add-info">Additional Info</label>
|
||||||
|
<input id="song-add-info" v-model="localSong['additional-info']" type="text">
|
||||||
|
|
||||||
|
<button @click="save">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
</PopupElement>
|
</PopupElement>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PopupElement from '../popups/PopupElement.vue';
|
||||||
|
|
||||||
|
const show = defineModel<boolean>();
|
||||||
|
const props = defineProps<{
|
||||||
|
'title': string;
|
||||||
|
'message'?: string;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
show.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<PopupElement v-model="show">
|
||||||
|
<h2>{{ props.title }}</h2>
|
||||||
|
<p>{{ props.message }}</p>
|
||||||
|
<button @click="close">
|
||||||
|
Ok
|
||||||
|
</button>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import PopupElement from '../popups/PopupElement.vue';
|
||||||
|
|
||||||
|
const show = defineModel<boolean>();
|
||||||
|
|
||||||
|
const close = () => {
|
||||||
|
show.value = false;
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- TODO: Beacon request when exiting browser (and confirm popup for that!) -->
|
||||||
|
<PopupElement v-model="show">
|
||||||
|
<h2>Settings</h2>
|
||||||
|
<button @click="close">
|
||||||
|
Ok
|
||||||
|
</button>
|
||||||
|
</PopupElement>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
currentQueue,
|
||||||
|
currentQueueIdx,
|
||||||
|
playbackTime,
|
||||||
|
showArtworks
|
||||||
|
} from '@/ts/shared/state';
|
||||||
|
import {
|
||||||
|
computed
|
||||||
|
} from 'vue';
|
||||||
|
|
||||||
|
const songs = computed( () => currentQueue.value?.slice( currentQueueIdx.value + 1 ) ?? [] );
|
||||||
|
const timeToPlay = computed( () => {
|
||||||
|
return ( idx: number ) => {
|
||||||
|
let total = 0;
|
||||||
|
|
||||||
|
for ( let i = 0; i < idx; i++ ) {
|
||||||
|
total += songs.value[ i ]!.duration;
|
||||||
|
}
|
||||||
|
|
||||||
|
total += currentQueue.value[ currentQueueIdx.value ]?.duration ?? 0;
|
||||||
|
total -= playbackTime.value;
|
||||||
|
|
||||||
|
return Math.ceil( total / 60 );
|
||||||
|
};
|
||||||
|
} );
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="queue-viewer">
|
||||||
|
<div class="queue-container">
|
||||||
|
<div v-if="songs.length > 0" class="queue-scroll">
|
||||||
|
<div v-for="(song, index) in songs" :key="index" class="song-list-element">
|
||||||
|
<div class="song-cover-wrapper">
|
||||||
|
<img
|
||||||
|
v-if="song.artwork && showArtworks"
|
||||||
|
:src="song.artwork"
|
||||||
|
alt="Song cover"
|
||||||
|
class="song-cover"
|
||||||
|
>
|
||||||
|
<i v-else class="fa-solid fa-music song-cover"></i>
|
||||||
|
</div>
|
||||||
|
<div class="song-details">
|
||||||
|
<h3>{{ song.name }}</h3>
|
||||||
|
<p>{{ song.artist }}</p>
|
||||||
|
<p>{{ song['additional-info'] }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="song-actions">
|
||||||
|
<p>In {{ '<' + timeToPlay( index ) }}min</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else class="queue-empty">
|
||||||
|
No upcoming songs
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/scss/components/queue.scss';
|
||||||
|
|
||||||
|
.queue-scroll {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 20px;
|
||||||
|
justify-content: flex-start !important;
|
||||||
|
overflow-y: scroll;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -7,22 +7,23 @@
|
|||||||
needsFiles,
|
needsFiles,
|
||||||
saveAssociations
|
saveAssociations
|
||||||
} from './associationManager';
|
} from './associationManager';
|
||||||
|
import {
|
||||||
|
fullPlayer,
|
||||||
|
queue
|
||||||
|
} from '@/ts/player/state';
|
||||||
import {
|
import {
|
||||||
onMounted,
|
onMounted,
|
||||||
useTemplateRef
|
useTemplateRef
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import PopupElement from '@/components/popups/PopupElement.vue';
|
import PopupElement from '@/components/popups/PopupElement.vue';
|
||||||
import player from '@/ts/player';
|
import player from '@/ts/player';
|
||||||
import {
|
|
||||||
queue
|
|
||||||
} from '@/ts/player/state';
|
|
||||||
|
|
||||||
const fileinput = useTemplateRef( 'fileinput' );
|
const fileinput = useTemplateRef( 'fileinput' );
|
||||||
|
|
||||||
onMounted( () => {
|
onMounted( () => {
|
||||||
fileinput.value?.addEventListener( 'change', async () => {
|
fileinput.value?.addEventListener( 'change', async () => {
|
||||||
if ( fileinput.value && fileinput.value.files ) {
|
if ( fileinput.value && fileinput.value.files ) {
|
||||||
associationResults.value.concat( await associationOpts.value?.get( fileinput.value.files ) ?? [] );
|
await associationOpts.value?.get( fileinput.value.files );
|
||||||
}
|
}
|
||||||
} );
|
} );
|
||||||
} );
|
} );
|
||||||
@@ -42,6 +43,18 @@
|
|||||||
|
|
||||||
associationResults.value.splice( idx, 1 );
|
associationResults.value.splice( idx, 1 );
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cancel = () => {
|
||||||
|
player.clearQueue();
|
||||||
|
isShowingAssociationManager.value = false;
|
||||||
|
fullPlayer.value = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const retry = () => {
|
||||||
|
associationResults.value = [];
|
||||||
|
isAnalyzing.value = false;
|
||||||
|
needsFiles.value = true;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -58,16 +71,15 @@
|
|||||||
:accept="associationOpts?.mime ?? '*'"
|
:accept="associationOpts?.mime ?? '*'"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="!needsFiles && !isAnalyzing && associationResults.length === 0">
|
<div v-if="needsFiles && !isAnalyzing && associationResults.length > 0" class="association-wrapper">
|
||||||
All files have been associated correctly
|
|
||||||
</div>
|
|
||||||
<div v-else-if="!needsFiles && !isAnalyzing && associationResults.length > 0">
|
|
||||||
<button @click="saveAssociations">
|
<button @click="saveAssociations">
|
||||||
Save
|
Save
|
||||||
</button>
|
</button>
|
||||||
<div v-for="(result, index) in associationResults" :key="index">
|
<div v-for="(result, index) in associationResults" :key="index" class="association">
|
||||||
<p>
|
<p>
|
||||||
{{ result.song.name }} by {{ result.song.artist }}
|
<b>{{ result.song.name.length > 30 ? result.song.name.slice( 0, 30 ) + '...' : result.song.name }}</b>
|
||||||
|
by
|
||||||
|
<i>{{ result.song.artist.length > 20 ? result.song.artist.slice( 0, 20 ) + '...' : result.song.artist }}</i>
|
||||||
</p>
|
</p>
|
||||||
<select v-if="result.match === 'multiple'">
|
<select v-if="result.match === 'multiple'">
|
||||||
<option v-for="(file, idx) in result.possibleFiles" :key="idx" :value="idx">
|
<option v-for="(file, idx) in result.possibleFiles" :key="idx" :value="idx">
|
||||||
@@ -82,10 +94,42 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="associationResults.length === 0"></div>
|
||||||
<div v-else>
|
<div v-else>
|
||||||
An error occurred. Please try again
|
An error occurred. Please try again
|
||||||
<!-- FIXME: Button to restore -->
|
<button @click="retry">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button @click="cancel">
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
</PopupElement>
|
</PopupElement>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.association-wrapper {
|
||||||
|
width: 60vw;
|
||||||
|
height: 50vh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: scroll;
|
||||||
|
|
||||||
|
.association {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 3rem;
|
||||||
|
|
||||||
|
>div, select {
|
||||||
|
display: flex;
|
||||||
|
margin-left: auto;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
>p {
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -19,7 +19,6 @@
|
|||||||
|
|
||||||
const search = ( ev: KeyboardEvent ) => {
|
const search = ( ev: KeyboardEvent ) => {
|
||||||
if ( ev.key === 'Enter' ) {
|
if ( ev.key === 'Enter' ) {
|
||||||
// TODO: If no new input, instead of searching, add first song
|
|
||||||
try {
|
try {
|
||||||
clearTimeout( timeout );
|
clearTimeout( timeout );
|
||||||
} catch { /* empty */ }
|
} catch { /* empty */ }
|
||||||
@@ -50,6 +49,10 @@
|
|||||||
|
|
||||||
addedIndex.value = idx;
|
addedIndex.value = idx;
|
||||||
searchOpts.value?.addSelected( idx );
|
searchOpts.value?.addSelected( idx );
|
||||||
|
|
||||||
|
if ( searchOpts.value?.autoClose )
|
||||||
|
isShowingSearchView.value = false;
|
||||||
|
|
||||||
addedTimeout = setTimeout( () => {
|
addedTimeout = setTimeout( () => {
|
||||||
addedIndex.value = -1;
|
addedIndex.value = -1;
|
||||||
}, 2000 );
|
}, 2000 );
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
AssociationResult
|
AssociationResult
|
||||||
} from '@/ts/player/plugins/interface';
|
} from '@/ts/player/plugins/interface';
|
||||||
|
import player from '@/ts/player';
|
||||||
import {
|
import {
|
||||||
queue
|
queue
|
||||||
} from '@/ts/player/state';
|
} from '@/ts/player/state';
|
||||||
@@ -21,7 +22,7 @@ export const isAnalyzing = ref( false );
|
|||||||
export const associationResults: Ref<AssociationResult[]> = ref( [] );
|
export const associationResults: Ref<AssociationResult[]> = ref( [] );
|
||||||
|
|
||||||
export const associationOpts: Ref<{
|
export const associationOpts: Ref<{
|
||||||
'get': ( files: FileList ) => Promise<AssociationResult[]>,
|
'get': ( files: FileList ) => Promise<void>,
|
||||||
'mime': string;
|
'mime': string;
|
||||||
} | null> = ref( null );
|
} | null> = ref( null );
|
||||||
|
|
||||||
@@ -30,8 +31,21 @@ export const openAssociationManager = ( cb: ( files: FileList ) => Promise<Assoc
|
|||||||
associationResults.value = [];
|
associationResults.value = [];
|
||||||
needsFiles.value = true;
|
needsFiles.value = true;
|
||||||
isAnalyzing.value = false;
|
isAnalyzing.value = false;
|
||||||
|
|
||||||
|
const callback = async ( files: FileList ): Promise<void> => {
|
||||||
|
const results = await cb( files );
|
||||||
|
|
||||||
|
|
||||||
|
if ( results?.length ?? -1 > 0 ) {
|
||||||
|
associationResults.value = associationResults.value.concat( results! );
|
||||||
|
} else {
|
||||||
|
isShowingAssociationManager.value = false;
|
||||||
|
player.playIndex( 0 );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
associationOpts.value = {
|
associationOpts.value = {
|
||||||
'get': cb,
|
'get': callback,
|
||||||
'mime': mime
|
'mime': mime
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -45,4 +59,9 @@ export const saveAssociations = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ( associationResults.value.length === 0 ) {
|
||||||
|
isShowingAssociationManager.value = false;
|
||||||
|
player.playIndex( 0 );
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface CloudImport {
|
|||||||
'search': ( term: string, offset: number ) => Promise<Song[]>;
|
'search': ( term: string, offset: number ) => Promise<Song[]>;
|
||||||
'addSelected': ( index: number ) => void;
|
'addSelected': ( index: number ) => void;
|
||||||
'minChars'?: number;
|
'minChars'?: number;
|
||||||
|
'autoClose': boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileImport {
|
export interface FileImport {
|
||||||
@@ -19,6 +20,7 @@ export interface FileImport {
|
|||||||
'type': 'file';
|
'type': 'file';
|
||||||
'mime': string;
|
'mime': string;
|
||||||
'process': ( files: FileList, cb: ( progress: number ) => void ) => Promise<void>;
|
'process': ( files: FileList, cb: ( progress: number ) => void ) => Promise<void>;
|
||||||
|
'autoClose': boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ImportTypes = CloudImport | FileImport;
|
export type ImportTypes = CloudImport | FileImport;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import '@fortawesome/fontawesome-free/css/all.css';
|
import '@fortawesome/fontawesome-free/css/all.css';
|
||||||
import App from './App.vue';
|
import App from './App.vue';
|
||||||
|
import Notifications from '@kyvg/vue3-notification';
|
||||||
import {
|
import {
|
||||||
createApp
|
createApp
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
@@ -12,5 +13,6 @@ const app = createApp( App );
|
|||||||
|
|
||||||
app.use( createPinia() );
|
app.use( createPinia() );
|
||||||
app.use( router );
|
app.use( router );
|
||||||
|
app.use( Notifications );
|
||||||
|
|
||||||
app.mount( '#app' );
|
app.mount( '#app' );
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ const router = createRouter( {
|
|||||||
'auth': true
|
'auth': true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
'path': '/share/:name',
|
||||||
|
'name': 'share',
|
||||||
|
'component': () => import( '@/views/SharedView.vue' ),
|
||||||
|
'meta': {
|
||||||
|
'title': 'Shared',
|
||||||
|
'auth': false
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
'path': '/:pathMatch(.*)*',
|
'path': '/:pathMatch(.*)*',
|
||||||
'name': 'NotFound',
|
'name': 'NotFound',
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.playlists {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
>.playlist-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
>.playlist-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow-x: scroll;
|
||||||
|
|
||||||
|
>.playlist {
|
||||||
|
width: 50%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px;
|
||||||
|
height: 3rem;
|
||||||
|
|
||||||
|
>h2, >input {
|
||||||
|
margin: 0;
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: 10px;
|
||||||
|
width: 100%;
|
||||||
|
text-align: start;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
>input {
|
||||||
|
margin-right: auto;
|
||||||
|
margin-left: 10px;
|
||||||
|
width: 50%;
|
||||||
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.fa-circle-play {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.fa-solid {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -33,5 +33,9 @@
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 10000;
|
z-index: 10000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.disallowed {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@
|
|||||||
|
|
||||||
&.moving {
|
&.moving {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
|
user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
>i {
|
>i {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
.shared-view {
|
||||||
|
width: 100%;
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-direction: row;
|
||||||
|
|
||||||
|
.panel {
|
||||||
|
width: 45%;
|
||||||
|
margin-left: 2.5%;
|
||||||
|
margin-right: 2.5%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
>.current-song-wrapper {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: calc(100% - 10rem);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.time {
|
||||||
|
display: flex;
|
||||||
|
width: 85%;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
|
||||||
|
>p {
|
||||||
|
margin-top: 0px;
|
||||||
|
margin-bottom: 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
>.duration {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
|
||||||
export const useAuthStore = defineStore( 'authstore', () => {
|
export const useAuthStore = defineStore( 'authstore', () => {
|
||||||
const isAuth = ref( true );
|
const isAuth = ref( false );
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAuth
|
isAuth
|
||||||
|
|||||||
Vendored
+8
@@ -4,4 +4,12 @@ import type {
|
|||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
var MusicKit: MusicKitObject;
|
var MusicKit: MusicKitObject;
|
||||||
|
interface GlobalEventHandlersEventMap {
|
||||||
|
'musicplayer:playpause': CustomEvent<void>;
|
||||||
|
'musicplayer:playindex': CustomEvent<void>;
|
||||||
|
'musicplayer:seek': CustomEvent<void>;
|
||||||
|
'musicplayer:update': CustomEvent<void>;
|
||||||
|
'musicplayer:reauth': CustomEvent<void>;
|
||||||
|
'musicplayer:autherror': CustomEvent<void>;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+4
-1
@@ -1,4 +1,7 @@
|
|||||||
// NOTE: For re-associating files with details here, can use dropdown in UI
|
export interface Playlist {
|
||||||
|
'name': string;
|
||||||
|
'songs': PlaylistSongs;
|
||||||
|
}
|
||||||
|
|
||||||
export type PlaylistSongs = Song[];
|
export type PlaylistSongs = Song[];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
import {
|
||||||
|
isPlaying,
|
||||||
|
queue,
|
||||||
|
queueIdx
|
||||||
|
} from '../player/state';
|
||||||
|
import {
|
||||||
|
onMounted,
|
||||||
|
onUnmounted,
|
||||||
|
ref,
|
||||||
|
watch
|
||||||
|
} from 'vue';
|
||||||
|
import {
|
||||||
|
playbackPercentage
|
||||||
|
} from '../player/status-tracking';
|
||||||
|
import {
|
||||||
|
reauth
|
||||||
|
} from '../util/reauth';
|
||||||
|
import request from '../request';
|
||||||
|
|
||||||
|
const RETRY_CAP = 10;
|
||||||
|
|
||||||
|
export const room = ref( localStorage.getItem( 'room' ) ?? '' );
|
||||||
|
|
||||||
|
export const isConnected = ref( false );
|
||||||
|
|
||||||
|
export const useAntiTamper = ref( false );
|
||||||
|
|
||||||
|
// TODO: Anit-Tamper
|
||||||
|
// const antiTamperClients = [];
|
||||||
|
|
||||||
|
let connection: null | EventSource = null;
|
||||||
|
let retries = 0;
|
||||||
|
|
||||||
|
const createRoom = async ( name: string, antiTamper: boolean ): Promise<boolean> => {
|
||||||
|
if ( !( /^[a-zA-Z0-9-]{3,20}$/ ).test( name ) ) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request.get( '/room/create?room=' + name );
|
||||||
|
} catch ( err ) {
|
||||||
|
if ( err === 'ERR_409' ) {
|
||||||
|
return await connect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem( 'room', name );
|
||||||
|
useAntiTamper.value = antiTamper;
|
||||||
|
room.value = name;
|
||||||
|
|
||||||
|
document.addEventListener( 'musicplayer:autherror', reauth );
|
||||||
|
|
||||||
|
return await connect();
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRoom = async () => {
|
||||||
|
// TODO: Trigger close on backend and disconnect.
|
||||||
|
isConnected.value = false;
|
||||||
|
localStorage.removeItem( 'room' );
|
||||||
|
connection?.close();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.removeEventListener( 'musicplayer:autherror', reauth );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
};
|
||||||
|
|
||||||
|
const connect = (): Promise<boolean> => {
|
||||||
|
return new Promise( ( resolve, reject ) => {
|
||||||
|
if ( !useAntiTamper.value ) {
|
||||||
|
isConnected.value = true;
|
||||||
|
sendPlaylistData();
|
||||||
|
sendStateData();
|
||||||
|
|
||||||
|
return resolve( true );
|
||||||
|
}
|
||||||
|
|
||||||
|
connection = new EventSource( request.backendURL + `/room/${ room.value }/admin`, {
|
||||||
|
'withCredentials': true
|
||||||
|
} );
|
||||||
|
|
||||||
|
connection.onopen = () => {
|
||||||
|
isConnected.value = true;
|
||||||
|
console.log( '[SSE] Connection established successfully' );
|
||||||
|
sendPlaylistData();
|
||||||
|
sendStateData();
|
||||||
|
resolve( true );
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.onmessage = msg => {
|
||||||
|
console.log( msg );
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.onerror = () => {
|
||||||
|
connection?.close();
|
||||||
|
console.error( '[SSE] Reconnecting due to error' );
|
||||||
|
|
||||||
|
if ( !isConnected.value ) reject( 'ERR_CONNECT' );
|
||||||
|
|
||||||
|
if ( retries <= RETRY_CAP ) {
|
||||||
|
retries += 1;
|
||||||
|
|
||||||
|
setTimeout( () => {
|
||||||
|
createRoom( room.value, useAntiTamper.value );
|
||||||
|
}, 1000 * retries );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} );
|
||||||
|
};
|
||||||
|
|
||||||
|
// FIXME: This is not a sensible solution, but easy for now (i.e. solve properly)
|
||||||
|
let playlistLock = false;
|
||||||
|
let stateLock = false;
|
||||||
|
|
||||||
|
const sendPlaylistData = () => {
|
||||||
|
if ( isConnected.value && !playlistLock ) {
|
||||||
|
playlistLock = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
request.post( `/room/${ room.value }/update/playlist`, JSON.stringify( {
|
||||||
|
'playlist': queue.value
|
||||||
|
} ) );
|
||||||
|
} catch ( e ) {
|
||||||
|
console.error( e );
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout( () => {
|
||||||
|
playlistLock = false;
|
||||||
|
}, 500 );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const sendStateData = async () => {
|
||||||
|
if ( isConnected.value && !stateLock ) {
|
||||||
|
stateLock = true;
|
||||||
|
|
||||||
|
try {
|
||||||
|
request.post( `/room/${ room.value }/update/state`, JSON.stringify( {
|
||||||
|
'playing': isPlaying.value,
|
||||||
|
'index': queueIdx.value,
|
||||||
|
'start': new Date().getTime() - 100,
|
||||||
|
'offset': playbackPercentage.value * ( queue.value[ queueIdx.value ]?.duration ?? 0 )
|
||||||
|
} ) );
|
||||||
|
} catch ( e ) {
|
||||||
|
console.error( e );
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout( () => {
|
||||||
|
stateLock = false;
|
||||||
|
}, 500 );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const useRoomWatchers = () => {
|
||||||
|
watch( queue, sendPlaylistData );
|
||||||
|
|
||||||
|
onMounted( () => {
|
||||||
|
document.addEventListener( 'musicplayer:playindex', sendStateData );
|
||||||
|
document.addEventListener( 'musicplayer:seek', sendStateData );
|
||||||
|
document.addEventListener( 'musicplayer:playpause', sendStateData );
|
||||||
|
document.addEventListener( 'musicplayer:update', sendPlaylistData );
|
||||||
|
} );
|
||||||
|
|
||||||
|
onUnmounted( () => {
|
||||||
|
try {
|
||||||
|
document.addEventListener( 'musicplayer:play', sendStateData );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.addEventListener( 'musicplayer:seek', sendStateData );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.addEventListener( 'musicplayer:playpause', sendStateData );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.addEventListener( 'musicplayer:update', sendPlaylistData );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
} );
|
||||||
|
|
||||||
|
if ( room.value ) connect();
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
createRoom,
|
||||||
|
useRoomWatchers,
|
||||||
|
closeRoom
|
||||||
|
};
|
||||||
|
|||||||
@@ -38,7 +38,9 @@ const next = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const prev = () => {
|
const prev = () => {
|
||||||
// TODO: Back to beginning if more than some seconds have passed?
|
if ( playbackPercentage.value * duration.value > 7 )
|
||||||
|
return seekTo( 0 );
|
||||||
|
|
||||||
playIndex( ( queueIdx.value - 1 + queue.value.length ) % queue.value.length );
|
playIndex( ( queueIdx.value - 1 + queue.value.length ) % queue.value.length );
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -49,6 +51,7 @@ const play = () => {
|
|||||||
|
|
||||||
sources[currentSource.value]?.play();
|
sources[currentSource.value]?.play();
|
||||||
startTracking();
|
startTracking();
|
||||||
|
document.dispatchEvent( new CustomEvent( 'musicplayer:playpause' ) );
|
||||||
};
|
};
|
||||||
|
|
||||||
const pause = () => {
|
const pause = () => {
|
||||||
@@ -58,6 +61,7 @@ const pause = () => {
|
|||||||
|
|
||||||
sources[currentSource.value]?.pause();
|
sources[currentSource.value]?.pause();
|
||||||
stopTracking();
|
stopTracking();
|
||||||
|
document.dispatchEvent( new CustomEvent( 'musicplayer:playpause' ) );
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -68,6 +72,7 @@ const seekTo = ( pos: number ) => {
|
|||||||
if ( currentSource.value === '' ) return;
|
if ( currentSource.value === '' ) return;
|
||||||
|
|
||||||
sources[currentSource.value]?.seekTo( pos );
|
sources[currentSource.value]?.seekTo( pos );
|
||||||
|
document.dispatchEvent( new CustomEvent( 'musicplayer:seek' ) );
|
||||||
};
|
};
|
||||||
|
|
||||||
const skip10 = () => {
|
const skip10 = () => {
|
||||||
@@ -135,10 +140,12 @@ const addToSongList = ( songs: Song[] ) => {
|
|||||||
/**
|
/**
|
||||||
* Add songs to the playlist from given source
|
* Add songs to the playlist from given source
|
||||||
* @param source - The ID of the source to add from
|
* @param source - The ID of the source to add from
|
||||||
|
* @param cb - A custom callback to be executed instead of the default, which adds to queue
|
||||||
|
* @param skipLogin - Whether to skip login checks
|
||||||
*/
|
*/
|
||||||
const addSongFromSource = async ( source: string ): Promise<boolean> => {
|
const addSongFromSource = async ( source: string, cb?: ( songs: Song[] ) => void, skipLogin?: boolean ): Promise<boolean> => {
|
||||||
if ( sources[source]!.authorized.value ) {
|
if ( sources[source]!.authorized.value || skipLogin ) {
|
||||||
sources[source]!.addSongsFromThisSource( addToSongList );
|
sources[source]!.addSongsFromThisSource( cb ? cb : addToSongList, skipLogin ? 0 : undefined, skipLogin );
|
||||||
} else {
|
} else {
|
||||||
sources[source]!.login!();
|
sources[source]!.login!();
|
||||||
|
|
||||||
@@ -148,7 +155,6 @@ const addSongFromSource = async ( source: string ): Promise<boolean> => {
|
|||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
play,
|
play,
|
||||||
pause,
|
pause,
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import {
|
|||||||
import type {
|
import type {
|
||||||
Song
|
Song
|
||||||
} from '@/ts/dtype/playlist';
|
} from '@/ts/dtype/playlist';
|
||||||
|
import {
|
||||||
|
setPlaylistIdx
|
||||||
|
} from '@/ts/userPlaylists';
|
||||||
|
|
||||||
export const addSongList = ( songs: Song[] ) => {
|
export const addSongList = ( songs: Song[] ) => {
|
||||||
rawQueue.value = rawQueue.value.concat( songs );
|
rawQueue.value = rawQueue.value.concat( songs );
|
||||||
@@ -22,6 +25,7 @@ export const addSongList = ( songs: Song[] ) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const clearQueue = () => {
|
export const clearQueue = () => {
|
||||||
|
setPlaylistIdx( 0 );
|
||||||
queue.value = [];
|
queue.value = [];
|
||||||
rawQueue.value = [];
|
rawQueue.value = [];
|
||||||
sources[currentSource.value]?.stop();
|
sources[currentSource.value]?.stop();
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
* Play a song at the given index of the queue. Wraps to 0 and end if index below 0 or above end
|
* Play a song at the given index of the queue. Wraps to 0 and end if index below 0 or above end
|
||||||
* @param idx - The index in the queue to play at
|
* @param idx - The index in the queue to play at
|
||||||
*/
|
*/
|
||||||
export const playIndex = async ( idx: number ) => {
|
export const playIndex = ( idx: number ) => {
|
||||||
if ( idx >= queue.value.length ) {
|
if ( idx >= queue.value.length ) {
|
||||||
idx = repeat.value === 'all' ? 0 : -1;
|
idx = repeat.value === 'all' ? 0 : -1;
|
||||||
} else if ( idx < 0 ) {
|
} else if ( idx < 0 ) {
|
||||||
@@ -42,4 +42,7 @@ export const playIndex = async ( idx: number ) => {
|
|||||||
startTracking();
|
startTracking();
|
||||||
|
|
||||||
isPlaying.value = true;
|
isPlaying.value = true;
|
||||||
|
setTimeout( () => {
|
||||||
|
document.dispatchEvent( new CustomEvent( 'musicplayer:playindex', {} ) );
|
||||||
|
}, 500 );
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
fullPlayer,
|
||||||
queue,
|
queue,
|
||||||
rawQueue,
|
rawQueue,
|
||||||
sources
|
sources
|
||||||
@@ -41,7 +42,16 @@ export const load = ( playlist: PlaylistSongs ) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if ( needToLoadLocalSongs ) {
|
if ( needToLoadLocalSongs ) {
|
||||||
// FIXME: Combine mime types
|
const mime = Object.values( sources )
|
||||||
openAssociationManager( fileLoader, '' );
|
.map( src => {
|
||||||
|
return src.loading.requiresLocalFiles === true ? src.loading.mime : '';
|
||||||
|
} )
|
||||||
|
.reduce( ( prev, curr ) => {
|
||||||
|
return prev === '' ? curr : prev + ',' + curr;
|
||||||
|
} );
|
||||||
|
|
||||||
|
openAssociationManager( fileLoader, mime );
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fullPlayer.value = true;
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-1
@@ -116,8 +116,11 @@ export interface PlayerSourcePlugin {
|
|||||||
/**
|
/**
|
||||||
* Called when user adds another song to the playlist via this source.
|
* Called when user adds another song to the playlist via this source.
|
||||||
* You may use the provided interface elements (search bar and popups) to e.g. ask if user wants to use a playlist, album, etc
|
* You may use the provided interface elements (search bar and popups) to e.g. ask if user wants to use a playlist, album, etc
|
||||||
|
* @param cb - Added songs are the arguments
|
||||||
|
* @param kindIdx - The index in the list of import types that should be used without showing popup. If unset, will show the picker
|
||||||
|
* @param autoClose - Whether or not to automatically close the interface after a single pick
|
||||||
*/
|
*/
|
||||||
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void ) => void;
|
'addSongsFromThisSource': ( cb: ( songs: Song[] ) => void, kindIdx?: number, autoClose?: boolean ) => void;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fully unload a song. This is called on clear of a playlist
|
* Fully unload a song. This is called on clear of a playlist
|
||||||
|
|||||||
@@ -8,58 +8,51 @@ import {
|
|||||||
import {
|
import {
|
||||||
musicKitPlayback
|
musicKitPlayback
|
||||||
} from './playback';
|
} from './playback';
|
||||||
|
import request from '@/ts/request';
|
||||||
|
|
||||||
export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => {
|
export const useMusicKit: PlayerSourcePluginInitializer = ( storefront: string = 'ch' ): Promise<PlayerSourcePlugin> => {
|
||||||
return new Promise( ( resolve, reject ) => {
|
return new Promise( ( resolve, reject ) => {
|
||||||
const init = async ( storefront: string ): Promise<PlayerSourcePlugin> => {
|
const init = async ( storefront: string ): Promise<PlayerSourcePlugin> => {
|
||||||
const res = await fetch( import.meta.env.VITE_BACKEND_URL + '/dev-token', {
|
const token = await ( await request.get( '/dev-token' ) ).text();
|
||||||
'credentials': 'include'
|
const instance = await window.MusicKit.configure( {
|
||||||
|
'developerToken': token,
|
||||||
|
'app': {
|
||||||
|
'name': 'MusicPlayer',
|
||||||
|
'build': '4'
|
||||||
|
// 'icon': 'https://music.janishutz.com/logo.jpg'
|
||||||
|
},
|
||||||
|
'storefrontId': storefront.toUpperCase()
|
||||||
} );
|
} );
|
||||||
|
const controls = musicKitPlayback( instance );
|
||||||
|
|
||||||
if ( res.status === 200 ) {
|
const login = async (): Promise<boolean> => {
|
||||||
const token = await res.text();
|
try {
|
||||||
const instance = await window.MusicKit.configure( {
|
await instance.authorize();
|
||||||
'developerToken': token,
|
|
||||||
'app': {
|
|
||||||
'name': 'MusicPlayer',
|
|
||||||
'build': '4'
|
|
||||||
// 'icon': 'https://music.janishutz.com/logo.jpg'
|
|
||||||
},
|
|
||||||
'storefrontId': storefront.toUpperCase()
|
|
||||||
} );
|
|
||||||
const controls = musicKitPlayback( instance );
|
|
||||||
|
|
||||||
const login = async (): Promise<boolean> => {
|
return true;
|
||||||
try {
|
} catch {
|
||||||
await instance.authorize();
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return true;
|
return {
|
||||||
} catch {
|
'authorized': controls.loggedIn,
|
||||||
return false;
|
'play': controls.play,
|
||||||
}
|
'pause': controls.pause,
|
||||||
};
|
'stop': controls.stop,
|
||||||
|
'playSong': controls.playSong,
|
||||||
return {
|
'getDuration': controls.getDuration,
|
||||||
'authorized': controls.loggedIn,
|
'getPlaybackPos': controls.getPlaybackPos,
|
||||||
'play': controls.play,
|
'id': 'applemusic',
|
||||||
'pause': controls.pause,
|
'name': 'Apple Music',
|
||||||
'stop': controls.stop,
|
'seekTo': controls.seekTo,
|
||||||
'playSong': controls.playSong,
|
'login': login,
|
||||||
'getDuration': controls.getDuration,
|
'addSongsFromThisSource': addFromAppleMusic,
|
||||||
'getPlaybackPos': controls.getPlaybackPos,
|
'loading': {
|
||||||
'id': 'applemusic',
|
'requiresLocalFiles': false
|
||||||
'name': 'Apple Music',
|
},
|
||||||
'seekTo': controls.seekTo,
|
'songUnload': async () => {}
|
||||||
'login': login,
|
};
|
||||||
'addSongsFromThisSource': addFromAppleMusic,
|
|
||||||
'loading': {
|
|
||||||
'requiresLocalFiles': false
|
|
||||||
},
|
|
||||||
'songUnload': async () => {}
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
throw new Error( 'ERR_AUTH' );
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if ( !window.MusicKit ) {
|
if ( !window.MusicKit ) {
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
|
import {
|
||||||
|
type CloudImport,
|
||||||
|
openImportTypePicker
|
||||||
|
} from '@/composables/importTypePicker';
|
||||||
import type {
|
import type {
|
||||||
Song
|
Song
|
||||||
} from '@/ts/dtype/playlist';
|
} from '@/ts/dtype/playlist';
|
||||||
import {
|
import {
|
||||||
openImportTypePicker
|
openSearchInterface
|
||||||
} from '@/composables/importTypePicker';
|
} from '@/composables/searchManager';
|
||||||
import {
|
import {
|
||||||
searchPlaylists
|
searchPlaylists
|
||||||
} from './playlists';
|
} from './playlists';
|
||||||
@@ -11,23 +15,29 @@ import {
|
|||||||
searchSongs
|
searchSongs
|
||||||
} from './songs';
|
} from './songs';
|
||||||
|
|
||||||
export const addFromAppleMusic = async ( cb: ( songs: Song[] ) => void ): Promise<void> => {
|
export const addFromAppleMusic = async ( cb: ( songs: Song[] ) => void, kindIdx?: number, autoClose?: boolean ): Promise<void> => {
|
||||||
const songs = await searchSongs( cb );
|
const songs = await searchSongs( cb );
|
||||||
const playlists = await searchPlaylists( cb );
|
const playlists = await searchPlaylists( cb );
|
||||||
|
const kinds: CloudImport[] = [
|
||||||
openImportTypePicker( [
|
|
||||||
{
|
{
|
||||||
'name': 'Songs',
|
'name': 'Songs',
|
||||||
'type': 'cloud',
|
'type': 'cloud',
|
||||||
'addSelected': songs.addSelected,
|
'addSelected': songs.addSelected,
|
||||||
'search': songs.search,
|
'search': songs.search,
|
||||||
'minChars': 3
|
'minChars': 3,
|
||||||
|
'autoClose': autoClose ?? false
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'name': 'Playlists',
|
'name': 'Playlists',
|
||||||
'type': 'cloud',
|
'type': 'cloud',
|
||||||
'addSelected': playlists.addSelected,
|
'addSelected': playlists.addSelected,
|
||||||
'search': playlists.search
|
'search': playlists.search,
|
||||||
|
'autoClose': autoClose ?? false
|
||||||
}
|
}
|
||||||
] );
|
];
|
||||||
|
|
||||||
|
if ( kindIdx === undefined )
|
||||||
|
openImportTypePicker( kinds );
|
||||||
|
else
|
||||||
|
openSearchInterface( kinds[kindIdx]! );
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const currentSource = ref( '' );
|
|||||||
|
|
||||||
export const rawQueue: Ref<PlaylistSongs> = ref( [] );
|
export const rawQueue: Ref<PlaylistSongs> = ref( [] );
|
||||||
|
|
||||||
export const queueIdx = ref( 0 );
|
export const queueIdx = ref( -1 );
|
||||||
|
|
||||||
export const queue: Ref<PlaylistSongs> = ref( [] );
|
export const queue: Ref<PlaylistSongs> = ref( [] );
|
||||||
|
|
||||||
@@ -36,6 +36,8 @@ export const shuffle = ref( false );
|
|||||||
|
|
||||||
export const repeat: Ref<RepeatMode> = ref( 'off' );
|
export const repeat: Ref<RepeatMode> = ref( 'off' );
|
||||||
|
|
||||||
|
export const fullPlayer = ref( false );
|
||||||
|
|
||||||
const initSources = async () => {
|
const initSources = async () => {
|
||||||
try {
|
try {
|
||||||
sources['applemusic'] = await useMusicKit();
|
sources['applemusic'] = await useMusicKit();
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export class AuthError extends Error {}
|
||||||
|
|
||||||
|
export class UnownedError extends Error {}
|
||||||
|
|
||||||
|
|
||||||
|
const get = async ( url: string ): Promise<Response> => {
|
||||||
|
return await wrapper( url, {
|
||||||
|
'credentials': 'include'
|
||||||
|
} );
|
||||||
|
};
|
||||||
|
|
||||||
|
const backendURL = import.meta.env.VITE_BACKEND_URL;
|
||||||
|
|
||||||
|
const post = async ( url: string, payload: string, mime: string = 'application/json' ): Promise<Response> => {
|
||||||
|
return await wrapper( url, {
|
||||||
|
'credentials': 'include',
|
||||||
|
'body': payload,
|
||||||
|
'method': 'post',
|
||||||
|
'headers': {
|
||||||
|
'Content-Type': mime ?? 'application/json'
|
||||||
|
}
|
||||||
|
} );
|
||||||
|
};
|
||||||
|
|
||||||
|
const wrapper = async ( url: string, opts: RequestInit ): Promise<Response> => {
|
||||||
|
const res = await fetch( backendURL + url, opts );
|
||||||
|
|
||||||
|
if ( res.ok ) {
|
||||||
|
return res;
|
||||||
|
} else if ( res.status === 403 || res.status === 401 ) {
|
||||||
|
document.dispatchEvent( new CustomEvent( 'musicplayer:autherror' ) );
|
||||||
|
|
||||||
|
throw new AuthError( 'ERR_USER_UNAUTHORIZED' );
|
||||||
|
} else if ( res.status === 402 ) {
|
||||||
|
throw new UnownedError( 'ERR_USER_UNOWNED' );
|
||||||
|
} else {
|
||||||
|
throw new Error( 'ERR_' + res.status );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
get,
|
||||||
|
post,
|
||||||
|
backendURL
|
||||||
|
};
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import {
|
||||||
|
type StateUpdate,
|
||||||
|
messageHandler
|
||||||
|
} from './messageHandler';
|
||||||
|
import {
|
||||||
|
currentQueue,
|
||||||
|
currentQueueIdx,
|
||||||
|
isPlaying,
|
||||||
|
playbackOffset,
|
||||||
|
playbackProgress,
|
||||||
|
playbackTime,
|
||||||
|
startTime
|
||||||
|
} from './state';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '../dtype/playlist';
|
||||||
|
import request from '../request';
|
||||||
|
|
||||||
|
const RETRY_CAP = 10;
|
||||||
|
|
||||||
|
let room = location.pathname;
|
||||||
|
let connection: EventSource | null = null;
|
||||||
|
let hasConnected = false;
|
||||||
|
let retries = 0;
|
||||||
|
|
||||||
|
// TODO: Persist settings in local storage
|
||||||
|
// TODO: Polling instead of sse as option
|
||||||
|
|
||||||
|
const connect = (): Promise<void> => {
|
||||||
|
return new Promise( ( resolve, reject ) => {
|
||||||
|
room = location.pathname.substring( location.pathname.lastIndexOf( '/' ) + 1 );
|
||||||
|
connection = new EventSource( request.backendURL + `/room/${ room }/connect` );
|
||||||
|
|
||||||
|
connection.onopen = async () => {
|
||||||
|
hasConnected = true;
|
||||||
|
console.log( '[SSE] Connection established successfully' );
|
||||||
|
const data = await ( await request.get( `/room/${ room }/poll` ) ).json() as {
|
||||||
|
'playlist': Song[],
|
||||||
|
'state': StateUpdate
|
||||||
|
};
|
||||||
|
|
||||||
|
currentQueue.value = data.playlist;
|
||||||
|
currentQueueIdx.value = data.state.index;
|
||||||
|
isPlaying.value = data.state.playing;
|
||||||
|
startTime.value = data.state.start;
|
||||||
|
|
||||||
|
if ( data.playlist.length === 0 )
|
||||||
|
playbackTime.value = 0;
|
||||||
|
else
|
||||||
|
playbackTime.value = data.state.offset;
|
||||||
|
|
||||||
|
playbackOffset.value = data.state.offset;
|
||||||
|
playbackProgress.value = data.state.offset / ( data.playlist[ data.state.index ]?.duration ?? -1 );
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.onmessage = msg => {
|
||||||
|
if ( msg.data === 'close' ) {
|
||||||
|
connection?.close();
|
||||||
|
|
||||||
|
// TODO: Show popup informing user that share was closed
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
messageHandler( msg.data );
|
||||||
|
};
|
||||||
|
|
||||||
|
connection.onerror = () => {
|
||||||
|
connection?.close();
|
||||||
|
|
||||||
|
if ( !hasConnected ) return reject( 'ERR_CONNECT' );
|
||||||
|
|
||||||
|
console.error( '[SSE] Connection failed, reconnecting' );
|
||||||
|
|
||||||
|
if ( retries <= RETRY_CAP ) {
|
||||||
|
retries += 1;
|
||||||
|
setTimeout( () => {
|
||||||
|
connect();
|
||||||
|
}, 1000 * retries );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
} );
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
connect
|
||||||
|
};
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import {
|
||||||
|
currentQueue,
|
||||||
|
currentQueueIdx,
|
||||||
|
isPlaying,
|
||||||
|
playbackOffset,
|
||||||
|
playbackTime,
|
||||||
|
startTime
|
||||||
|
} from './state';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '../dtype/playlist';
|
||||||
|
|
||||||
|
interface ReceivedJSONMessage {
|
||||||
|
'type': 'state' | 'playlist';
|
||||||
|
'data': unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface StateUpdate {
|
||||||
|
'playing': boolean;
|
||||||
|
'index': number;
|
||||||
|
'start': number;
|
||||||
|
'offset': number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const messageHandler = ( msg: string ) => {
|
||||||
|
if ( msg.startsWith( 'json:' ) ) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse( msg.substring( 5 ) ) as ReceivedJSONMessage;
|
||||||
|
|
||||||
|
if ( data.type === 'playlist' ) {
|
||||||
|
currentQueue.value = ( data.data as {
|
||||||
|
'playlist': Song[]
|
||||||
|
} ?? {
|
||||||
|
'playlist': []
|
||||||
|
} ).playlist;
|
||||||
|
} else if ( data.type === 'state' ) {
|
||||||
|
const state = data.data as StateUpdate;
|
||||||
|
|
||||||
|
currentQueueIdx.value = state.index;
|
||||||
|
isPlaying.value = state.playing;
|
||||||
|
startTime.value = state.start;
|
||||||
|
playbackTime.value = state.offset;
|
||||||
|
playbackOffset.value = state.offset;
|
||||||
|
} else {
|
||||||
|
console.log( '[SSE] Received unknown data', data.type );
|
||||||
|
}
|
||||||
|
} catch ( err ) {
|
||||||
|
console.error( 'JSON DECODE failed with error', err );
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import {
|
||||||
|
type Ref,
|
||||||
|
ref
|
||||||
|
} from 'vue';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '../dtype/playlist';
|
||||||
|
|
||||||
|
export const currentQueue: Ref<Song[]> = ref( [] );
|
||||||
|
|
||||||
|
export const isPlaying = ref( false );
|
||||||
|
|
||||||
|
export const currentQueueIdx = ref( -1 );
|
||||||
|
|
||||||
|
export const startTime = ref( new Date().getTime() );
|
||||||
|
|
||||||
|
export const showArtworks = ref( false );
|
||||||
|
|
||||||
|
export const playbackTime = ref( 0 );
|
||||||
|
|
||||||
|
export const playbackOffset = ref( 0 );
|
||||||
|
|
||||||
|
export const playbackProgress = ref( 0 );
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import {
|
||||||
|
editingPlaylists,
|
||||||
|
playlistIdx,
|
||||||
|
playlists
|
||||||
|
} from './state';
|
||||||
|
import player from '../player';
|
||||||
|
|
||||||
|
|
||||||
|
export const addPlaylist = ( name: string ) => {
|
||||||
|
playlists.value.push( {
|
||||||
|
'name': name,
|
||||||
|
'songs': []
|
||||||
|
} );
|
||||||
|
editingPlaylists.value.push( false );
|
||||||
|
};
|
||||||
|
|
||||||
|
export const removePlaylist = ( idx: number ) => {
|
||||||
|
if ( confirm( 'Do you really want to delete this playlist?' ) ) {
|
||||||
|
playlists.value.splice( idx, 1 );
|
||||||
|
editingPlaylists.value.splice( idx, 1 );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const selectPlaylist = ( idx: number ) => {
|
||||||
|
playlistIdx.value = idx;
|
||||||
|
player.clearQueue();
|
||||||
|
player.loadPlaylist( playlists.value[ idx ]!.songs );
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setPlaylistIdx = ( idx: number ) => {
|
||||||
|
playlistIdx.value = idx;
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,10 +1,64 @@
|
|||||||
|
import {
|
||||||
|
editingPlaylists,
|
||||||
|
playlistIdx,
|
||||||
|
playlists
|
||||||
|
} from './state';
|
||||||
import {
|
import {
|
||||||
queue,
|
queue,
|
||||||
rawQueue,
|
rawQueue,
|
||||||
shuffle
|
shuffle
|
||||||
} from '../player/state';
|
} from '../player/state';
|
||||||
|
import {
|
||||||
|
addPlaylist
|
||||||
|
} from '.';
|
||||||
|
import request from '../request';
|
||||||
|
import {
|
||||||
|
useNotification
|
||||||
|
} from '@kyvg/vue3-notification';
|
||||||
|
|
||||||
const savePlaylist = () => {
|
export const savePlaylist = () => {
|
||||||
rawQueue.value = queue.value;
|
rawQueue.value = queue.value;
|
||||||
shuffle.value = false;
|
shuffle.value = false;
|
||||||
|
|
||||||
|
if ( playlistIdx.value < 0 ) {
|
||||||
|
let name: null | string = '';
|
||||||
|
|
||||||
|
while ( !name || name.length === 0 ) {
|
||||||
|
name = prompt( 'You are trying to save a playlist that has not previously been created. Please enter a name for it' );
|
||||||
|
|
||||||
|
if ( !name ) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addPlaylist( name );
|
||||||
|
playlistIdx.value = playlists.value.length - 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
playlists.value[ playlistIdx.value ]!.songs = rawQueue.value;
|
||||||
|
|
||||||
|
savePlaylists();
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPlaylists = async () => {
|
||||||
|
playlists.value = await ( await request.get( '/user/playlists' ) ).json();
|
||||||
|
editingPlaylists.value = playlists.value.map( () => false );
|
||||||
|
};
|
||||||
|
|
||||||
|
export const savePlaylists = async () => {
|
||||||
|
const notifications = useNotification();
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request.post( '/user/playlists', JSON.stringify( playlists.value ) );
|
||||||
|
notifications.notify( {
|
||||||
|
'text': 'Playlists saved successfully',
|
||||||
|
'type': 'success',
|
||||||
|
'title': 'Playlists'
|
||||||
|
} );
|
||||||
|
} catch ( e ) {
|
||||||
|
console.error( e );
|
||||||
|
notifications.notify( {
|
||||||
|
'text': 'Failed to save playlists',
|
||||||
|
'type': 'error',
|
||||||
|
'title': 'Playlists'
|
||||||
|
} );
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ import {
|
|||||||
ref
|
ref
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
import type {
|
import type {
|
||||||
PlaylistSongs
|
Playlist
|
||||||
} from '../dtype/playlist';
|
} from './file';
|
||||||
|
|
||||||
const currentPlaylist = ref( '' );
|
export const playlistIdx = ref( -1 );
|
||||||
const playlists: Ref<PlaylistSongs> = ref( [] );
|
|
||||||
|
export const playlists: Ref<Playlist[]> = ref( [] );
|
||||||
|
|
||||||
|
export const editingPlaylists: Ref<boolean[]> = ref( [] );
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export const reauth = () => {
|
||||||
|
localStorage.setItem( 'close-tab', 'true' );
|
||||||
|
|
||||||
|
const listener = () => {
|
||||||
|
if ( localStorage.getItem( 'reauth-ok' ) === 'true' ) {
|
||||||
|
try {
|
||||||
|
window.removeEventListener( 'storage', listener );
|
||||||
|
} catch { /* empty */ }
|
||||||
|
|
||||||
|
localStorage.removeItem( 'reauth-ok' );
|
||||||
|
|
||||||
|
document.dispatchEvent( new CustomEvent( 'musicplayer:reauth' ) );
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener( 'storage', listener );
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
type ComputedRef,
|
||||||
|
computed,
|
||||||
|
onMounted
|
||||||
|
} from 'vue';
|
||||||
|
import {
|
||||||
|
currentQueue,
|
||||||
|
currentQueueIdx,
|
||||||
|
isPlaying,
|
||||||
|
playbackOffset,
|
||||||
|
playbackProgress,
|
||||||
|
playbackTime,
|
||||||
|
startTime
|
||||||
|
} from '@/ts/shared/state';
|
||||||
|
import CurrentSong from '@/components/player/CurrentSong.vue';
|
||||||
|
import ProgressBar from '@/components/player/ProgressBar.vue';
|
||||||
|
import SharedQueue from '@/components/shared/SharedQueue.vue';
|
||||||
|
import type {
|
||||||
|
Song
|
||||||
|
} from '@/ts/dtype/playlist';
|
||||||
|
import {
|
||||||
|
beautifyTime
|
||||||
|
} from '@/ts/util/time';
|
||||||
|
import shared from '@/ts/shared';
|
||||||
|
|
||||||
|
shared.connect();
|
||||||
|
|
||||||
|
onMounted( () => {
|
||||||
|
setInterval( () => {
|
||||||
|
if ( !isPlaying.value ) return;
|
||||||
|
|
||||||
|
playbackTime.value = ( ( new Date().getTime() - startTime.value ) / 1000 ) + playbackOffset.value;
|
||||||
|
playbackProgress.value = playbackTime.value / song.value.duration;
|
||||||
|
|
||||||
|
if ( playbackTime.value > song.value.duration ) {
|
||||||
|
playbackOffset.value -= song.value.duration;
|
||||||
|
|
||||||
|
if ( currentQueueIdx.value < currentQueue.value.length - 1 )
|
||||||
|
currentQueueIdx.value++;
|
||||||
|
else
|
||||||
|
isPlaying.value = false;
|
||||||
|
}
|
||||||
|
}, 250 );
|
||||||
|
} );
|
||||||
|
const song: ComputedRef<Song> = computed( () => {
|
||||||
|
if ( currentQueueIdx.value >= 0 && currentQueue.value.length > currentQueueIdx.value )
|
||||||
|
return currentQueue.value[currentQueueIdx.value]!;
|
||||||
|
else
|
||||||
|
return {
|
||||||
|
'artist': 'No artist',
|
||||||
|
'duration': -1,
|
||||||
|
'name': 'Not playing',
|
||||||
|
'artwork': '',
|
||||||
|
'additional-info': '',
|
||||||
|
'identifier': 'nosong-ident',
|
||||||
|
'source': 'local'
|
||||||
|
};
|
||||||
|
} );
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="shared-view">
|
||||||
|
<div class="panel">
|
||||||
|
<div class="current-song-wrapper">
|
||||||
|
<CurrentSong v-model="song" :show-additional-info="true" />
|
||||||
|
</div>
|
||||||
|
<ProgressBar v-model="playbackProgress" :disallow-move="true" />
|
||||||
|
<div class="time">
|
||||||
|
<p class="current">
|
||||||
|
{{ beautifyTime( song.duration >= 0 ? playbackTime : -1 ) }}
|
||||||
|
</p>
|
||||||
|
<p class="duration">
|
||||||
|
{{ beautifyTime( song.duration ) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="panel">
|
||||||
|
<SharedQueue />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@use '@/scss/shared/main.scss';
|
||||||
|
</style>
|
||||||
|
|||||||
+49
-24
@@ -1,36 +1,61 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import SortableList from '@/components/SortableList.vue';
|
|
||||||
import {
|
import {
|
||||||
|
onMounted,
|
||||||
ref
|
ref
|
||||||
} from 'vue';
|
} from 'vue';
|
||||||
|
import router from '@/router';
|
||||||
|
import sdk from '@janishutz/login-sdk-browser';
|
||||||
|
import {
|
||||||
|
useAuthStore
|
||||||
|
} from '@/stores/authstore';
|
||||||
|
|
||||||
const items = ref( [
|
const isLoggingIn = ref( true );
|
||||||
'test',
|
const store = useAuthStore();
|
||||||
'test2',
|
|
||||||
'test3',
|
sdk.setUp( 'jh-music', 'http://localhost:8080', '/app' );
|
||||||
'test4',
|
|
||||||
'test5',
|
onMounted( async () => {
|
||||||
'test6',
|
try {
|
||||||
'test7',
|
store.isAuth = await sdk.verify();
|
||||||
'test8',
|
|
||||||
'test9',
|
if ( store.isAuth )
|
||||||
'test10'
|
isAuthorizedHandler();
|
||||||
] );
|
} catch ( e ) {
|
||||||
|
if ( e !== 'ERR_401' ) {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoggingIn.value = false;
|
||||||
|
|
||||||
|
// TODO: Logout button
|
||||||
|
} );
|
||||||
|
|
||||||
|
const isAuthorizedHandler = () => {
|
||||||
|
if ( localStorage.getItem( 'close-tab' ) === 'true' ) {
|
||||||
|
localStorage.setItem( 'login-ok', 'true' );
|
||||||
|
localStorage.removeItem( 'close-tab' );
|
||||||
|
|
||||||
|
return window.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
router.push( '/app' );
|
||||||
|
};
|
||||||
|
|
||||||
|
const login = () => {
|
||||||
|
if ( isLoggingIn.value ) return;
|
||||||
|
|
||||||
|
isLoggingIn.value = true;
|
||||||
|
sdk.login();
|
||||||
|
isLoggingIn.value = false;
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<h1>MusicPlayer</h1>
|
<h1>MusicPlayer</h1>
|
||||||
<div class="test">
|
<button :class="['fancy-button', isLoggingIn ? 'fancy-button-inactive' : undefined]" @click="login">
|
||||||
<SortableList v-slot="{ item, index }" v-model="items">
|
Log In
|
||||||
{{ item }} at {{ index }}
|
</button>
|
||||||
</SortableList>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.test {
|
|
||||||
height: 150px;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user