diff --git a/README.md b/README.md
index 746baf6..4e6ea66 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# dizqueTV 1.1.1
+# dizqueTV 1.2.0-prerelease
  
Create live TV channel streams from media on your Plex servers.
diff --git a/resources/favicon-16.png b/resources/favicon-16.png
index 324257e..8cb92cb 100644
Binary files a/resources/favicon-16.png and b/resources/favicon-16.png differ
diff --git a/resources/favicon-32.png b/resources/favicon-32.png
index abecf16..09f9c90 100644
Binary files a/resources/favicon-32.png and b/resources/favicon-32.png differ
diff --git a/src/api.js b/src/api.js
index 006c8ae..953dfd4 100644
--- a/src/api.js
+++ b/src/api.js
@@ -8,6 +8,7 @@ const FFMPEGInfo = require('./ffmpeg-info');
const PlexServerDB = require('./dao/plex-server-db');
const Plex = require("./plex.js");
const FillerDB = require('./dao/filler-db');
+const timeSlotsService = require('./services/time-slots-service');
module.exports = { router: api }
function api(db, channelDB, fillerDB, xmltvInterval, guideService ) {
@@ -303,6 +304,10 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) {
try {
db['ffmpeg-settings'].update({ _id: req.body._id }, req.body)
let ffmpeg = db['ffmpeg-settings'].find()[0]
+ let err = fixupFFMPEGSettings(ffmpeg);
+ if (typeof(err) !== 'undefined') {
+ return res.status(400).send(err);
+ }
res.send(ffmpeg)
} catch(err) {
console.error(err);
@@ -323,6 +328,14 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) {
})
+ function fixupFFMPEGSettings(ffmpeg) {
+ if (typeof(ffmpeg.maxFPS) === 'undefined') {
+ ffmpeg.maxFPS = 60;
+ } else if ( isNaN(ffmpeg.maxFPS) ) {
+ return "maxFPS should be a number";
+ }
+ }
+
// PLEX SETTINGS
router.get('/api/plex-settings', (req, res) => {
try {
@@ -528,6 +541,20 @@ function api(db, channelDB, fillerDB, xmltvInterval, guideService ) {
})
+ //tool services
+ router.post('/api/channel-tools/time-slots', async (req, res) => {
+ try {
+ let toolRes = await timeSlotsService(req.body.programs, req.body.schedule);
+ if ( typeof(toolRes.userError) !=='undefined') {
+ return res.status(400).send(toolRes.userError);
+ }
+ res.status(200).send(toolRes);
+ } catch(err) {
+ console.error(err);
+ res.status(500).send("Internal error");
+ }
+ });
+
// CHANNELS.M3U Download
router.get('/api/channels.m3u', async (req, res) => {
try {
diff --git a/src/constants.js b/src/constants.js
index c53a056..0b3c66a 100644
--- a/src/constants.js
+++ b/src/constants.js
@@ -1,9 +1,9 @@
module.exports = {
SLACK: 9999,
TVGUIDE_MAXIMUM_PADDING_LENGTH_MS: 30*60*1000,
- STEALTH_DURATION: 5 * 60* 1000,
+ DEFAULT_GUIDE_STEALTH_DURATION: 5 * 60* 1000,
TVGUIDE_MAXIMUM_FLEX_DURATION : 6 * 60 * 60 * 1000,
TOO_FREQUENT: 100,
- VERSION_NAME: "1.1.1"
+ VERSION_NAME: "1.2.0-prerelease"
}
diff --git a/src/database-migration.js b/src/database-migration.js
index 90f9995..bfab008 100644
--- a/src/database-migration.js
+++ b/src/database-migration.js
@@ -20,7 +20,7 @@
const path = require('path');
var fs = require('fs');
-const TARGET_VERSION = 600;
+const TARGET_VERSION = 700;
const STEPS = [
// [v, v2, x] : if the current version is v, call x(db), and version becomes v2
@@ -31,6 +31,8 @@ const STEPS = [
[ 400, 500, (db,channels) => splitServersSingleChannels(db, channels) ],
[ 500, 501, (db) => fixCorruptedServer(db) ],
[ 501, 600, () => extractFillersFromChannels() ],
+ [ 600, 601, (db) => addFPS(db) ],
+ [ 601, 700, (db) => migrateWatermark(db) ],
]
const { v4: uuidv4 } = require('uuid');
@@ -392,6 +394,7 @@ function ffmpeg() {
normalizeAudioCodec: true,
normalizeResolution: true,
normalizeAudio: true,
+ maxFPS: 60,
}
}
@@ -662,6 +665,87 @@ function extractFillersFromChannels() {
}
+function addFPS(db) {
+ let ffmpegSettings = db['ffmpeg-settings'].find()[0];
+ let f = path.join(process.env.DATABASE, 'ffmpeg-settings.json');
+ ffmpegSettings.maxFPS = 60;
+ fs.writeFileSync( f, JSON.stringify( [ffmpegSettings] ) );
+}
+
+function migrateWatermark(db, channelDB) {
+ let ffmpegSettings = db['ffmpeg-settings'].find()[0];
+ let w = 1920;
+ let h = 1080;
+
+ function parseResolutionString(s) {
+ var i = s.indexOf('x');
+ if (i == -1) {
+ i = s.indexOf("×");
+ if (i == -1) {
+ return {w:1920, h:1080}
+ }
+ }
+ return {
+ w: parseInt( s.substring(0,i) , 10 ),
+ h: parseInt( s.substring(i+1) , 10 ),
+ }
+ }
+
+ if (
+ (ffmpegSettings.targetResolution != null)
+ && (typeof(ffmpegSettings.targetResolution) !== 'undefined')
+ && (typeof(ffmpegSettings.targetResolution) !== '')
+ ) {
+ let p = parseResolutionString( ffmpegSettings.targetResolution );
+ w = p.w;
+ h = p.h;
+ }
+ console.log(`Using ${w}x${h} as resolution to migrate new watermark settings.`);
+ function migrateChannel(channel) {
+ if (channel.overlayIcon === true) {
+ channel.watermark = {
+ enabled: true,
+ width: Math.max(0.001, Math.min(100, (channel.iconWidth*100) / w ) ),
+ verticalMargin: Math.max(0.000, Math.min(100, 2000 / h ) ),
+ horizontalMargin: Math.max(0.000, Math.min(100, 2000 / w ) ),
+ duration: channel.iconDuration,
+ fixedSize: false,
+ position: [
+ "top-left",
+ "top-right",
+ "bottom-left",
+ "bottom-right",
+ ][ channel.iconPosition ],
+ url: '', //same as channel icon
+ animated: false,
+ }
+ } else {
+ channel.watermark = {
+ enabled: false,
+ }
+ }
+ delete channel.overlayIcon;
+ delete channel.iconDuration;
+ delete channel.iconPosition;
+ delete channel.iconWidth;
+ return channel;
+ }
+
+ console.log("Extracting fillers from channels...");
+ let channels = path.join(process.env.DATABASE, 'channels');
+ let channelFiles = fs.readdirSync(channels);
+ for (let i = 0; i < channelFiles.length; i++) {
+ if (path.extname( channelFiles[i] ) === '.json') {
+ console.log("Migrating watermark in channel : " + channelFiles[i] +"..." );
+ let channelPath = path.join(channels, channelFiles[i]);
+ let channel = JSON.parse(fs.readFileSync(channelPath, 'utf-8'));
+ channel = migrateChannel(channel);
+ fs.writeFileSync( channelPath, JSON.stringify(channel), 'utf-8');
+ }
+ }
+ console.log("Done migrating watermarks in channels.");
+}
+
module.exports = {
initDB: initDB,
defaultFFMPEG: ffmpeg,
diff --git a/src/ffmpeg.js b/src/ffmpeg.js
index 801b9ba..1e461be 100644
--- a/src/ffmpeg.js
+++ b/src/ffmpeg.js
@@ -2,6 +2,7 @@ const spawn = require('child_process').spawn
const events = require('events')
const MAXIMUM_ERROR_DURATION_MS = 60000;
+const REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE = 120;
class FFMPEG extends events.EventEmitter {
constructor(opts, channel) {
@@ -9,7 +10,7 @@ class FFMPEG extends events.EventEmitter {
this.opts = opts;
this.errorPicturePath = `http://localhost:${process.env.PORT}/images/generic-error-screen.png`;
if (! this.opts.enableFFMPEGTranscoding) {
- //this ensures transcoding is completely disabled even if
+ //this ensures transcoding is completely disabled even if
// some settings are true
this.opts.normalizeAudio = false;
this.opts.normalizeAudioCodec = false;
@@ -17,11 +18,40 @@ class FFMPEG extends events.EventEmitter {
this.opts.errorScreen = 'kill';
this.opts.normalizeResolution = false;
this.opts.audioVolumePercent = 100;
+ this.opts.maxFPS = REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE;
}
this.channel = channel
this.ffmpegPath = opts.ffmpegPath
- var parsed = parseResolutionString(opts.targetResolution);
+ let resString = opts.targetResolution;
+ if (
+ (typeof(channel.transcoding) !== 'undefined')
+ && (channel.transcoding.targetResolution != null)
+ && (typeof(channel.transcoding.targetResolution) != 'undefined')
+ && (channel.transcoding.targetResolution != "")
+ ) {
+ resString = channel.transcoding.targetResolution;
+ }
+
+ if (
+ (typeof(channel.transcoding) !== 'undefined')
+ && (channel.transcoding.videoBitrate != null)
+ && (typeof(channel.transcoding.videoBitrate) != 'undefined')
+ && (channel.transcoding.videoBitrate != 0)
+ ) {
+ opts.videoBitrate = channel.transcoding.videoBitrate;
+ }
+
+ if (
+ (typeof(channel.transcoding) !== 'undefined')
+ && (channel.transcoding.videoBufSize != null)
+ && (typeof(channel.transcoding.videoBufSize) != 'undefined')
+ && (channel.transcoding.videoBufSize != 0)
+ ) {
+ opts.videoBufSize = channel.transcoding.videoBufSize;
+ }
+
+ let parsed = parseResolutionString(resString);
this.wantedW = parsed.w;
this.wantedH = parsed.h;
@@ -71,7 +101,7 @@ class FFMPEG extends events.EventEmitter {
};
return await this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false);
}
- async spawn(streamUrl, streamStats, startTime, duration, limitRead, enableIcon, type, isConcatPlaylist) {
+ async spawn(streamUrl, streamStats, startTime, duration, limitRead, watermark, type, isConcatPlaylist) {
let ffmpegArgs = [
`-threads`, isConcatPlaylist? 1 : this.opts.threads,
@@ -108,7 +138,7 @@ class FFMPEG extends events.EventEmitter {
// When we have an individual stream, there is a pipeline of possible
// filters to apply.
//
- var doOverlay = enableIcon;
+ var doOverlay = ( (typeof(watermark)==='undefined') || (watermark != null) );
var iW = streamStats.videoWidth;
var iH = streamStats.videoHeight;
@@ -129,6 +159,11 @@ class FFMPEG extends events.EventEmitter {
// When adding filters, make sure that
// videoComplex always begins wiht ; and doesn't end with ;
+ if ( streamStats.videoFramerate >= this.opts.maxFPS + 0.000001 ) {
+ videoComplex += `;${currentVideo}fps=${this.opts.maxFPS}[fpchange]`;
+ currentVideo ="[fpchange]";
+ }
+
// prepare input streams
if ( typeof(streamUrl.errorTitle) !== 'undefined') {
doOverlay = false; //never show icon in the error screen
@@ -210,8 +245,12 @@ class FFMPEG extends events.EventEmitter {
currentAudio = "[audiox]";
}
if (doOverlay) {
- ffmpegArgs.push(`-i`, `${this.channel.icon}` );
+ if (watermark.animated === true) {
+ ffmpegArgs.push('-ignore_loop', '0');
+ }
+ ffmpegArgs.push(`-i`, `${watermark.url}` );
overlayFile = inputFiles++;
+ this.ensureResolution = true;
}
// Resolution fix: Add scale filter, current stream becomes [siz]
@@ -263,26 +302,48 @@ class FFMPEG extends events.EventEmitter {
currentVideo = "blackpadded";
}
let name = "siz";
- if (! this.ensureResolution) {
+ if (! this.ensureResolution && (beforeSizeChange != '[fpchange]') ) {
name = "minsiz";
}
videoComplex += `;[${currentVideo}]setsar=1[${name}]`;
currentVideo = `[${name}]`;
+ iW = this.wantedW;
+ iH = this.wantedH;
}
- // Channel overlay:
+ // Channel watermark:
if (doOverlay) {
- if (process.env.DEBUG) console.log('Channel Icon Overlay Enabled')
+ var pW =watermark.width;
+ var w = Math.round( pW * iW / 100.0 );
+ var mpHorz = watermark.horizontalMargin;
+ var mpVert = watermark.verticalMargin;
+ var horz = Math.round( mpHorz * iW / 100.0 );
+ var vert = Math.round( mpVert * iH / 100.0 );
- let posAry = [ '20:20', 'W-w-20:20', '20:H-h-20', 'W-w-20:H-h-20'] // top-left, top-right, bottom-left, bottom-right (with 20px padding)
+ let posAry = {
+ 'top-left': `${horz}:${vert}`,
+ 'top-right': `W-w-${horz}:${vert}`,
+ 'bottom-left': `${horz}:H-h-${vert}`,
+ 'bottom-right': `W-w-${horz}:H-h-${vert}`,
+ }
let icnDur = ''
-
- if (this.channel.iconDuration > 0)
- icnDur = `:enable='between(t,0,${this.channel.iconDuration})'`
-
- videoComplex += `;[${overlayFile}:v]scale=${this.channel.iconWidth}:-1[icn];${currentVideo}[icn]overlay=${posAry[this.channel.iconPosition]}${icnDur}[comb]`
+ if (watermark.duration > 0) {
+ icnDur = `:enable='between(t,0,${watermark.duration})'`
+ }
+ let waterVideo = `[${overlayFile}:v]`;
+ if ( ! watermark.fixedSize) {
+ videoComplex += `;${waterVideo}scale=${w}:-1[icn]`;
+ waterVideo = '[icn]';
+ }
+ let p = posAry[watermark.position];
+ if (typeof(p) === 'undefined') {
+ throw Error("Invalid watermark position: " + watermark.position);
+ }
+ videoComplex += `;${currentVideo}${waterVideo}overlay=${p}${icnDur}[comb]`
currentVideo = '[comb]';
}
+
+
if (this.volumePercent != 100) {
var f = this.volumePercent / 100.0;
audioComplex += `;${currentAudio}volume=${f}[boosted]`;
diff --git a/src/helperFuncs.js b/src/helperFuncs.js
index d232f42..aaffd4c 100644
--- a/src/helperFuncs.js
+++ b/src/helperFuncs.js
@@ -1,7 +1,7 @@
module.exports = {
getCurrentProgramAndTimeElapsed: getCurrentProgramAndTimeElapsed,
createLineup: createLineup,
- isChannelIconEnabled: isChannelIconEnabled,
+ getWatermark: getWatermark,
}
let channelCache = require('./channel-cache');
@@ -253,19 +253,43 @@ function pickRandomWithMaxDuration(channel, fillers, maxDuration) {
}
}
-function isChannelIconEnabled( ffmpegSettings, channel, type) {
+function getWatermark( ffmpegSettings, channel, type) {
if (! ffmpegSettings.enableFFMPEGTranscoding || ffmpegSettings.disableChannelOverlay ) {
- return false;
+ return null;
}
let d = channel.disableFillerOverlay;
if (typeof(d) === 'undefined') {
d = true;
}
if ( (typeof type !== `undefined`) && (type == 'commercial') && d ) {
- return false;
+ return null;
}
- if (channel.icon === '' || !channel.overlayIcon) {
- return false;
+ let e = false;
+ let icon = undefined;
+ let watermark = {};
+ if (typeof(channel.watermark) !== 'undefined') {
+ watermark = channel.watermark;
+ e = (watermark.enabled === true);
+ icon = watermark.url;
}
- return true;
+ if (! e) {
+ return null;
+ }
+ if ( (typeof(icon) === 'undefined') || (icon === '') ) {
+ icon = channel.icon;
+ if ( (typeof(icon) === 'undefined') || (icon === '') ) {
+ return null;
+ }
+ }
+ let result = {
+ url: icon,
+ width: watermark.width,
+ verticalMargin: watermark.verticalMargin,
+ horizontalMargin: watermark.horizontalMargin,
+ duration: watermark.duration,
+ position: watermark.position,
+ fixedSize: (watermark.fixedSize === true),
+ animated: (watermark.animated === true),
+ }
+ return result;
}
diff --git a/src/plex-player.js b/src/plex-player.js
index 73ba96f..69c69aa 100644
--- a/src/plex-player.js
+++ b/src/plex-player.js
@@ -57,7 +57,7 @@ class PlexPlayer {
let plexSettings = db['plex-settings'].find()[0];
let plexTranscoder = new PlexTranscoder(this.clientId, server, plexSettings, channel, lineupItem);
this.plexTranscoder = plexTranscoder;
- let enableChannelIcon = this.context.enableChannelIcon;
+ let watermark = this.context.watermark;
let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options
this.ffmpeg = ffmpeg;
let streamDuration;
@@ -81,7 +81,7 @@ class PlexPlayer {
let emitter = new EventEmitter();
//setTimeout( () => {
- let ff = await ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, enableChannelIcon, lineupItem.type); // Spawn the ffmpeg process
+ let ff = await ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, watermark, lineupItem.type); // Spawn the ffmpeg process
ff.pipe(outStream, {'end':false} );
//}, 100);
plexTranscoder.startUpdatingPlex();
diff --git a/src/program-player.js b/src/program-player.js
index 576cff9..260ff10 100644
--- a/src/program-player.js
+++ b/src/program-player.js
@@ -51,7 +51,7 @@ class ProgramPlayer {
/* plex */
this.delegate = new PlexPlayer(context);
}
- this.context.enableChannelIcon = helperFuncs.isChannelIconEnabled( context.ffmpegSettings, context.channel, context.lineupItem.type);
+ this.context.watermark = helperFuncs.getWatermark( context.ffmpegSettings, context.channel, context.lineupItem.type);
}
cleanUp() {
diff --git a/src/services/time-slots-service.js b/src/services/time-slots-service.js
new file mode 100644
index 0000000..d50ad78
--- /dev/null
+++ b/src/services/time-slots-service.js
@@ -0,0 +1,384 @@
+const constants = require("../constants");
+
+const MINUTE = 60*1000;
+const DAY = 24*60*MINUTE;
+const LIMIT = 40000;
+
+
+//This is a triplicate code, but maybe it doesn't have to be?
+function getShow(program) {
+ //used for equalize and frequency tweak
+ if (program.isOffline) {
+ if (program.type == 'redirect') {
+ return {
+ description : `Redirect to channel ${program.channel}`,
+ id: "redirect." + program.channel,
+ channel: program.channel,
+ }
+ } else {
+ return null;
+ }
+ } else if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) {
+ return {
+ description: program.showTitle,
+ id: "tv." + program.showTitle,
+ }
+ } else {
+ return {
+ description: "Movies",
+ id: "movie.",
+ }
+ }
+}
+
+
+function shuffle(array, lo, hi ) {
+ if (typeof(lo) === 'undefined') {
+ lo = 0;
+ hi = array.length;
+ }
+ let currentIndex = hi, temporaryValue, randomIndex
+ while (lo !== currentIndex) {
+ randomIndex = lo + Math.floor(Math.random() * (currentIndex -lo) );
+ currentIndex -= 1
+ temporaryValue = array[currentIndex]
+ array[currentIndex] = array[randomIndex]
+ array[randomIndex] = temporaryValue
+ }
+ return array
+}
+
+function _wait(t) {
+ return new Promise((resolve) => {
+ setTimeout(resolve, t);
+ });
+}
+
+function getProgramId(program) {
+ let s = program.serverKey;
+ if (typeof(s) === 'undefined') {
+ s = 'unknown';
+ }
+ let p = program.key;
+ if (typeof(p) === 'undefined') {
+ p = 'unknown';
+ }
+ return s + "|" + p;
+}
+
+function addProgramToShow(show, program) {
+ if ( (show.id == 'flex.') || show.id.startsWith("redirect.") ) {
+ //nothing to do
+ return;
+ }
+ let id = getProgramId(program)
+ if(show.programs[id] !== true) {
+ show.programs.push(program);
+ show.programs[id] = true
+ }
+}
+
+function getShowOrderer(show) {
+ if (typeof(show.orderer) === 'undefined') {
+
+ let sortedPrograms = JSON.parse( JSON.stringify(show.programs) );
+ sortedPrograms.sort((a, b) => {
+ if (a.season === b.season) {
+ if (a.episode > b.episode) {
+ return 1
+ } else {
+ return -1
+ }
+ } else if (a.season > b.season) {
+ return 1;
+ } else if (b.season > a.season) {
+ return -1;
+ } else {
+ return 0
+ }
+ });
+
+ let position = 0;
+ while (
+ (position + 1 < sortedPrograms.length )
+ &&
+ (
+ show.founder.season !== sortedPrograms[position].season
+ ||
+ show.founder.episode !== sortedPrograms[position].episode
+ )
+ ) {
+ position++;
+ }
+
+
+ show.orderer = {
+
+ current : () => {
+ return sortedPrograms[position];
+ },
+
+ next: () => {
+ position = (position + 1) % sortedPrograms.length;
+ },
+
+ }
+ }
+ return show.orderer;
+}
+
+
+function getShowShuffler(show) {
+ if (typeof(show.shuffler) === 'undefined') {
+ if (typeof(show.programs) === 'undefined') {
+ throw Error(show.id + " has no programs?")
+ }
+
+ let randomPrograms = JSON.parse( JSON.stringify(show.programs) );
+ let n = randomPrograms.length;
+ shuffle( randomPrograms, 0, n);
+ let position = 0;
+
+ show.shuffler = {
+
+ current : () => {
+ return randomPrograms[position];
+ },
+
+ next: () => {
+ position++;
+ if (position == n) {
+ let a = Math.floor(n / 2);
+ shuffle(randomPrograms, 0, a );
+ shuffle(randomPrograms, a, n );
+ position = 0;
+ }
+ },
+
+ }
+ }
+ return show.shuffler;
+}
+
+module.exports = async( programs, schedule ) => {
+ if (! Array.isArray(programs) ) {
+ return { userError: 'Expected a programs array' };
+ }
+ if (typeof(schedule) === 'undefined') {
+ return { userError: 'Expected a schedule' };
+ }
+ if (typeof(schedule.timeZoneOffset) === 'undefined') {
+ return { userError: 'Expected a time zone offset' };
+ }
+ //verify that the schedule is in the correct format
+ if (! Array.isArray(schedule.slots) ) {
+ return { userError: 'Expected a "slots" array in schedule' };
+ }
+ for (let i = 0; i < schedule.slots.length; i++) {
+ if (typeof(schedule.slots[i].time) === 'undefined') {
+ return { userError: "Each slot should have a time" };
+ }
+ if (typeof(schedule.slots[i].showId) === 'undefined') {
+ return { userError: "Each slot should have a showId" };
+ }
+ if (
+ (schedule.slots[i].time < 0)
+ || (schedule.slots[i].time >= DAY)
+ || (Math.floor(schedule.slots[i].time) != schedule.slots[i].time)
+ ) {
+ return { userError: "Slot times should be a integer number of milliseconds since the start of the day." };
+ }
+ schedule.slots[i].time = ( schedule.slots[i].time + 10*DAY + schedule.timeZoneOffset*MINUTE) % DAY;
+ }
+ schedule.slots.sort( (a,b) => {
+ return (a.time - b.time);
+ } );
+ for (let i = 1; i < schedule.slots.length; i++) {
+ if (schedule.slots[i].time == schedule.slots[i-1].time) {
+ return { userError: "Slot times should be unique."};
+ }
+ }
+ if (typeof(schedule.pad) === 'undefined') {
+ return { userError: "Expected schedule.pad" };
+ }
+
+ if (typeof(schedule.lateness) == 'undefined') {
+ return { userError: "schedule.lateness must be defined." };
+ }
+ if (typeof(schedule.maxDays) == 'undefined') {
+ return { userError: "schedule.maxDays must be defined." };
+ }
+
+ // throttle so that the stream is not affected negatively
+ let steps = 0;
+ let throttle = async() => {
+ if (steps++ == 10) {
+ steps = 0;
+ await _wait(1);
+ }
+ }
+
+ let showsById = {};
+ let shows = [];
+
+ function getNextForSlot(slot, remaining) {
+ if (slot.showId === "flex.") {
+ return {
+ isOffline: true,
+ duration: remaining,
+ }
+ }
+ let show = shows[ showsById[slot.showId] ];
+ if (slot.showId.startsWith("redirect.")) {
+ return {
+ isOffline: true,
+ type: "redirect",
+ duration: remaining,
+ channel: show.channel,
+ }
+ } else if (slot.order === 'shuffle') {
+ return getShowShuffler(show).current();
+ } else if (slot.order === 'next') {
+ return getShowOrderer(show).current();
+ }
+ }
+
+ function advanceSlot(slot) {
+ if ( (slot.showId === "flex.") || (slot.showId.startsWith("redirect") ) ) {
+ return;
+ }
+ let show = shows[ showsById[slot.showId] ];
+ if (slot.order === 'shuffle') {
+ return getShowShuffler(show).next();
+ } else if (slot.order === 'next') {
+ return getShowOrderer(show).next();
+ }
+ }
+
+ // load the programs
+ for (let i = 0; i < programs.length; i++) {
+ let p = programs[i];
+ let show = getShow(p);
+ if (show != null) {
+ if (typeof(showsById[show.id] ) === 'undefined') {
+ showsById[show.id] = shows.length;
+ shows.push( show );
+ show.founder = p;
+ show.programs = [];
+ } else {
+ show = shows[ showsById[show.id] ];
+ }
+ addProgramToShow( show, p );
+ }
+ }
+
+ let s = schedule.slots;
+ let d = (new Date() );
+ d.setUTCMilliseconds(0);
+ d.setUTCSeconds(0);
+ d.setUTCMinutes(0);
+ d.setUTCHours(0);
+ d.setUTCMilliseconds( s[0].time );
+ let t0 = d.getTime();
+ let p = [];
+ let t = t0;
+ let previous = null;
+ let hardLimit = t0 + schedule.maxDays * DAY;
+
+ let pushFlex = (d) => {
+ if (d > 0) {
+ t += d;
+ if ( (p.length > 0) && p[p.length-1].isOffline && (p[p.length-1].type != 'redirect') ) {
+ p[p.length-1].duration += d;
+ } else {
+ p.push( {
+ duration: d,
+ isOffline : true,
+ } );
+ }
+ }
+ }
+
+ for (let i = 0; i < LIMIT; i++) {
+ await throttle();
+ let dayTime = t % DAY;
+ let slot = null;
+ let remaining = null;
+ for (let i = 0; i < s.length; i++) {
+ let endTime;
+ if (i == s.length - 1) {
+ endTime = s[0].time + DAY;
+ } else {
+ endTime = s[i+1].time;
+ }
+
+ if ((s[i].time <= dayTime) && (dayTime < endTime)) {
+ slot = s[i];
+ remaining = endTime - dayTime;
+ break;
+ }
+ if ((s[i].time <= dayTime + DAY) && (dayTime + DAY < endTime)) {
+ slot = s[i];
+ dayTime += DAY;
+ remaining = endTime - dayTime;
+ break;
+ }
+ }
+ if (slot == null) {
+ throw Error("Unexpected. Unable to find slot for time of day " + t + " " + dayTime);
+ }
+
+ let first = (previous !== slot.showId);
+ let skip = false; //skips to the next one
+ if (first) {
+ //check if it's too late
+ let d = dayTime - slot.time;
+ if (d >= schedule.lateness + constants.SLACK) {
+ skip = true;
+ }
+ }
+ let item = getNextForSlot(slot, remaining);
+ if ( (item.duration >= remaining + constants.SLACK) && !first) {
+ skip = true;
+ }
+
+ if (t + item.duration - constants.SLACK >= hardLimit) {
+ pushFlex( hardLimit - t );
+ break;
+ }
+ if (item.isOffline && item.type != 'redirect') {
+ //it's the same, really
+ skip = true;
+ }
+ if (skip) {
+ pushFlex(remaining);
+ } else {
+ previous = slot.showId;
+ let clone = JSON.parse( JSON.stringify(item) );
+ clone.$index = p.length;
+ p.push( clone );
+ t += clone.duration;
+
+ advanceSlot(slot);
+ }
+ let nt = t;
+ let m = t % schedule.pad;
+ if (m != 0) {
+ nt = t - m + schedule.pad;
+ let remaining = nt - t;
+ if (remaining >= constants.SLACK) {
+ pushFlex(remaining);
+ }
+ }
+ }
+
+ return {
+ programs: p,
+ startTime: (new Date(t0)).toISOString(),
+ }
+
+}
+
+
+
+
diff --git a/src/svg/favicon.svg b/src/svg/favicon.svg
index c70bd88..2d33dcd 100644
--- a/src/svg/favicon.svg
+++ b/src/svg/favicon.svg
@@ -15,7 +15,10 @@
version="1.1"
id="svg8"
inkscape:version="0.92.5 (2060ec1f9f, 2020-04-08)"
- sodipodi:docname="favicon.svg">
+ sodipodi:docname="favicon.svg"
+ inkscape:export-filename="/home/vx/dev/pseudotv/resources/favicon-32.png"
+ inkscape:export-xdpi="15.36"
+ inkscape:export-ydpi="15.36">
image/svg+xml
-
+
@@ -55,57 +58,64 @@
id="layer1"
transform="translate(0,-244.08278)">
-
-
-
+ id="g4581"
+ style="fill:#1f1f1f;fill-opacity:1;stroke-width:0.45839298"
+ transform="matrix(2.1815344,0,0,2.1815344,-157.46714,-279.05712)">
-
-
-
-
+ transform="rotate(-0.94645665)"
+ y="239.28041"
+ x="65.156158"
+ height="27.75024"
+ width="41.471352"
+ id="rect4524"
+ style="opacity:1;fill:#1f1f1f;fill-opacity:1;stroke:#000000;stroke-width:0;stroke-miterlimit:4;stroke-dasharray:none;stroke-dashoffset:0;stroke-opacity:1" />
+
+
+
+
+
+ d
diff --git a/src/tv-guide-service.js b/src/tv-guide-service.js
index 8d69b41..e56225b 100644
--- a/src/tv-guide-service.js
+++ b/src/tv-guide-service.js
@@ -173,10 +173,10 @@ class TVGuideService
await this._throttle();
if (
(programs.length > 0)
- && isProgramFlex(x.program)
+ && isProgramFlex(x.program, channel)
&& (
(x.program.duration <= constants.TVGUIDE_MAXIMUM_PADDING_LENGTH_MS)
- || isProgramFlex(programs[ programs.length - 1].program)
+ || isProgramFlex(programs[ programs.length - 1].program, channel)
)
) {
//meld with previous
@@ -185,7 +185,7 @@ class TVGuideService
melded += x.program.duration;
if (
(melded > constants.TVGUIDE_MAXIMUM_PADDING_LENGTH_MS)
- && !isProgramFlex(programs[ programs.length - 1].program)
+ && !isProgramFlex(programs[ programs.length - 1].program, channel)
) {
y.program.duration -= melded;
programs[ programs.length - 1] = y;
@@ -200,7 +200,7 @@ class TVGuideService
} else {
programs[ programs.length - 1] = y;
}
- } else if (isProgramFlex(x.program) ) {
+ } else if (isProgramFlex(x.program, channel) ) {
melded = 0;
programs.push( {
start: x.start,
@@ -217,12 +217,14 @@ class TVGuideService
while (x.start < t1) {
await push(x);
x = await this.getChannelPlaying(channel, x, x.start + x.program.duration);
- if (x.program.duration == 0) throw Error("D");
+ if (x.program.duration == 0) {
+ console.error("There's a program with duration 0?");
+ }
}
result.programs = [];
for (let i = 0; i < programs.length; i++) {
await this._throttle();
- if (isProgramFlex( programs[i].program) ) {
+ if (isProgramFlex( programs[i].program, channel) ) {
let start = programs[i].start;
let duration = programs[i].program.duration;
if (start <= t0) {
@@ -391,10 +393,21 @@ function _wait(t) {
}
+function getChannelStealthDuration(channel) {
+ if (
+ (typeof(channel.guideMinimumDurationSeconds) !== 'undefined')
+ &&
+ ! isNaN(channel.guideMinimumDurationSeconds)
+ ) {
+ return channel.guideMinimumDurationSeconds * 1000;
+ } else {
+ return constants.DEFAULT_GUIDE_STEALTH_DURATION;
+ }
+
+}
-
-function isProgramFlex(program) {
- return program.isOffline || program.duration <= constants.STEALTH_DURATION
+function isProgramFlex(program, channel) {
+ return program.isOffline || program.duration <= getChannelStealthDuration(channel)
}
function clone(o) {
@@ -413,8 +426,13 @@ function makeEntry(channel, x) {
let title = undefined;
let icon = undefined;
let sub = undefined;
- if (isProgramFlex(x.program)) {
- title = channel.name;
+ if (isProgramFlex(x.program, channel)) {
+ if ( (typeof(channel.guideFlexPlaceholder) === 'string')
+ && channel.guideFlexPlaceholder !== "") {
+ title = channel.guideFlexPlaceholder;
+ } else {
+ title = channel.name;
+ }
icon = channel.icon;
} else {
title = x.program.showTitle;
diff --git a/src/video.js b/src/video.js
index 4ebab8f..d82073d 100644
--- a/src/video.js
+++ b/src/video.js
@@ -287,11 +287,13 @@ function video( channelDB , fillerDB, db) {
};
}
+ let combinedChannel = JSON.parse( JSON.stringify(brandChannel) );
+ combinedChannel.transcoding = channel.transcoding;
let playerContext = {
lineupItem : lineupItem,
ffmpegSettings : ffmpegSettings,
- channel: brandChannel,
+ channel: combinedChannel,
db: db,
m3u8: m3u8,
}
diff --git a/web/app.js b/web/app.js
index b608412..af58820 100644
--- a/web/app.js
+++ b/web/app.js
@@ -9,6 +9,7 @@ var app = angular.module('myApp', ['ngRoute', 'vs-repeat', 'angularLazyImg', 'dn
app.service('plex', require('./services/plex'))
app.service('dizquetv', require('./services/dizquetv'))
+app.service('resolutionOptions', require('./services/resolution-options'))
app.directive('plexSettings', require('./directives/plex-settings'))
app.directive('ffmpegSettings', require('./directives/ffmpeg-settings'))
@@ -24,6 +25,7 @@ app.directive('removeShows', require('./directives/remove-shows'))
app.directive('channelRedirect', require('./directives/channel-redirect'))
app.directive('plexServerEdit', require('./directives/plex-server-edit'))
app.directive('channelConfig', require('./directives/channel-config'))
+app.directive('timeSlotsScheduleEditor', require('./directives/time-slots-schedule-editor'))
app.controller('settingsCtrl', require('./controllers/settings'))
app.controller('channelsCtrl', require('./controllers/channels'))
diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js
index 3c9fca1..a7b9cbf 100644
--- a/web/directives/channel-config.js
+++ b/web/directives/channel-config.js
@@ -1,4 +1,4 @@
-module.exports = function ($timeout, $location, dizquetv) {
+module.exports = function ($timeout, $location, dizquetv, resolutionOptions) {
return {
restrict: 'E',
templateUrl: 'templates/channel-config.html',
@@ -9,24 +9,37 @@ module.exports = function ($timeout, $location, dizquetv) {
channel: "=channel",
onDone: "=onDone"
},
- link: function (scope, element, attrs) {
+ link: {
+
+ post: function (scope, element, attrs) {
+ scope.screenW = 1920;
+ scope.screenh = 1080;
+
scope.maxSize = 50000;
+
+ scope.blockCount = 1;
+ scope.showShuffleOptions = (localStorage.getItem("channel-tools") === "on");
+ scope.reverseTools = (localStorage.getItem("channel-tools-position") === "left");
scope.hasFlex = false;
- scope.showHelp = false;
+ scope.showHelp = { check: false }
scope._frequencyModified = false;
scope._frequencyMessage = "";
scope.minProgramIndex = 0;
scope.libraryLimit = 50000;
+ scope.displayPlexLibrary = false;
scope.episodeMemory = {
saved : false,
};
if (typeof scope.channel === 'undefined' || scope.channel == null) {
scope.channel = {}
scope.channel.programs = []
+ scope.channel.watermark = defaultWatermark();
scope.channel.fillerCollections = []
+ scope.channel.guideFlexPlaceholder = "";
scope.channel.fillerRepeatCooldown = 30 * 60 * 1000;
scope.channel.fallback = [];
+ scope.channel.guideMinimumDurationSeconds = 5 * 60;
scope.isNewChannel = true
scope.channel.icon = `${$location.protocol()}://${location.host}/images/dizquetv.png`
scope.channel.disableFillerOverlay = true;
@@ -51,26 +64,16 @@ module.exports = function ($timeout, $location, dizquetv) {
scope.channel.name = "Channel 1"
}
scope.showRotatedNote = false;
+ scope.channel.transcoding = {
+ targetResolution: "",
+ }
} else {
scope.beforeEditChannelNumber = scope.channel.number
- let t = Date.now();
- let originalStart = scope.channel.startTime.getTime();
- let n = scope.channel.programs.length;
- let totalDuration = scope.channel.duration;
- let m = (t - originalStart) % totalDuration;
- let x = 0;
- let runningProgram = -1;
- let offset = 0;
- for (let i = 0; i < n; i++) {
- let d = scope.channel.programs[i].duration;
- if (x + d > m) {
- runningProgram = i
- offset = m - x;
- break;
- } else {
- x += d;
- }
+
+ if (typeof(scope.channel.watermark) === 'undefined') {
+ scope.channel.watermark = defaultWatermark();
}
+
if (typeof(scope.channel.fillerRepeatCooldown) === 'undefined') {
scope.channel.fillerRepeatCooldown = 30 * 60 * 1000;
}
@@ -91,14 +94,121 @@ module.exports = function ($timeout, $location, dizquetv) {
if (typeof(scope.channel.disableFillerOverlay) === 'undefined') {
scope.channel.disableFillerOverlay = true;
}
- scope.channel.startTime = new Date(t - offset);
- // move runningProgram to index 0
- scope.channel.programs = scope.channel.programs.slice(runningProgram, this.length)
- .concat(scope.channel.programs.slice(0, runningProgram) );
+ if (
+ (typeof(scope.channel.guideMinimumDurationSeconds) === 'undefined')
+ || isNaN(scope.channel.guideMinimumDurationSeconds)
+ ) {
+ scope.channel.guideMinimumDurationSeconds = 5 * 60;
+ }
+
+ if (typeof(scope.channel.transcoding) ==='undefined') {
+ scope.channel.transcoding = {};
+ }
+ if (
+ (scope.channel.transcoding.targetResolution == null)
+ || (typeof(scope.channel.transcoding.targetResolution) === 'undefined')
+ || (scope.channel.transcoding.targetResolution === '')
+ ) {
+ scope.channel.transcoding.targetResolution = "";
+ }
+
+
+ adjustStartTimeToCurrentProgram();
updateChannelDuration();
setTimeout( () => { scope.showRotatedNote = true }, 1, 'funky');
}
+ function defaultWatermark() {
+ return {
+ enabled: false,
+ position: "bottom-right",
+ width: 10.00,
+ verticalMargin: 0.00,
+ horizontalMargin: 0.00,
+ duration: 0,
+ }
+ }
+
+ function adjustStartTimeToCurrentProgram() {
+ let t = Date.now();
+ let originalStart = scope.channel.startTime.getTime();
+ let n = scope.channel.programs.length;
+ let totalDuration = scope.channel.duration;
+ let m = (t - originalStart) % totalDuration;
+ let x = 0;
+ let runningProgram = -1;
+ let offset = 0;
+ for (let i = 0; i < n; i++) {
+ let d = scope.channel.programs[i].duration;
+ if (x + d > m) {
+ runningProgram = i
+ offset = m - x;
+ break;
+ } else {
+ x += d;
+ }
+ }
+ // move runningProgram to index 0
+ scope.channel.programs = scope.channel.programs.slice(runningProgram)
+ .concat(scope.channel.programs.slice(0, runningProgram) );
+ scope.channel.startTime = new Date(t - offset);
+
+ }
+
+
+
+ let addMinuteVersionsOfFields = () => {
+ //add the minutes versions of the cooldowns:
+ scope.channel.fillerRepeatCooldownMinutes = scope.channel.fillerRepeatCooldown / 1000 / 60;
+ for (let i = 0; i < scope.channel.fillerCollections.length; i++) {
+ scope.channel.fillerCollections[i].cooldownMinutes = scope.channel.fillerCollections[i].cooldown / 1000 / 60;
+
+ }
+ }
+ addMinuteVersionsOfFields();
+
+ let removeMinuteVersionsOfFields = (channel) => {
+ channel.fillerRepeatCooldown = channel.fillerRepeatCooldownMinutes * 60 * 1000;
+ delete channel.fillerRepeatCooldownMinutes;
+ for (let i = 0; i < channel.fillerCollections.length; i++) {
+ channel.fillerCollections[i].cooldown = channel.fillerCollections[i].cooldownMinutes * 60 * 1000;
+ delete channel.fillerCollections[i].cooldownMinutes;
+ }
+ }
+
+ scope.tabOptions = [
+ { name: "Properties", id: "basic" },
+ { name: "Programming", id: "programming" },
+ { name: "Flex", id: "flex" },
+ { name: "EPG", id: "epg" },
+ { name: "FFmpeg", id: "ffmpeg" },
+ ];
+ scope.setTab = (tab) => {
+ scope.tab = tab;
+ }
+
+ if (scope.isNewChannel) {
+ scope.tab = "basic";
+ } else {
+ scope.tab = "programming";
+ }
+
+ scope.getTitle = () => {
+ if (scope.isNewChannel) {
+ return "Create Channel";
+ } else {
+ let x = "?";
+ if ( (scope.channel.number != null) && ( typeof(scope.channel.number) !== 'undefined') && (! isNaN(scope.channel.number) ) ) {
+ x = "" + scope.channel.number;
+ }
+ let y = "Unnamed";
+ if (typeof(scope.channel.name) !== 'undefined') {
+ y = scope.channel.name;
+ }
+ return `${x} - ${y}`;
+ }
+ }
+
scope._selectedRedirect = {
isOffline : true,
type : "redirect",
@@ -123,34 +233,9 @@ module.exports = function ($timeout, $location, dizquetv) {
return true;
}
- let fixFillerCollection = (f) => {
- return {
- id: f.id,
- weight: f.weight,
- cooldown: f.cooldown * 60000,
- };
- }
- let unfixFillerCollection = (f) => {
- return {
- id: f.id,
- weight: f.weight,
- cooldown: Math.floor(f.cooldown / 60000),
- };
- }
-
- scope.updateChannelFromOfflineResult = (program) => {
- scope.channel.offlineMode = program.channelOfflineMode;
- scope.channel.offlinePicture = program.channelPicture;
- scope.channel.offlineSoundtrack = program.channelSound;
- scope.channel.fillerRepeatCooldown = program.repeatCooldown * 60000;
- scope.channel.fillerCollections = JSON.parse( angular.toJson(program.filler.map(fixFillerCollection) ) );
- scope.channel.fallback = JSON.parse( angular.toJson(program.fallback) );
- scope.channel.disableFillerOverlay = program.disableOverlay;
- }
scope.finishedOfflineEdit = (program) => {
let editedProgram = scope.channel.programs[scope.selectedProgram];
let duration = program.durationSeconds * 1000;
- scope.updateChannelFromOfflineResult(program);
editedProgram.duration = duration;
editedProgram.isOffline = true;
scope._selectedOffline = null
@@ -162,7 +247,6 @@ module.exports = function ($timeout, $location, dizquetv) {
duration: duration,
isOffline: true
}
- scope.updateChannelFromOfflineResult(result);
scope.channel.programs.splice(scope.minProgramIndex, 0, program);
scope._selectedOffline = null
scope._addingOffline = null;
@@ -265,6 +349,19 @@ module.exports = function ($timeout, $location, dizquetv) {
});
updateChannelDuration()
}
+ scope.slideAllPrograms = (offset) => {
+ let t0 = scope.channel.startTime.getTime();
+ let t1 = t0 - offset;
+ let t = (new Date()).getTime();
+ let total = scope.channel.duration;
+ while(t1 > t) {
+ //TODO: Replace with division
+ t1 -= total;
+ }
+ scope.channel.startTime = new Date(t1);
+ adjustStartTimeToCurrentProgram();
+ updateChannelDuration();
+ }
scope.removeDuplicates = () => {
let tmpProgs = {}
let progs = scope.channel.programs
@@ -414,7 +511,7 @@ module.exports = function ($timeout, $location, dizquetv) {
}
let f = interpolate;
- let w = 5.0;
+ let w = 15.0;
let t = 4*60*60*1000;
//let d = Math.log( Math.min(t, program.duration) ) / Math.log(2);
//let a = (d * Math.log(2) ) / Math.log(t);
@@ -863,14 +960,7 @@ module.exports = function ($timeout, $location, dizquetv) {
}
scope.makeOfflineFromChannel = (duration) => {
return {
- channelOfflineMode: scope.channel.offlineMode,
- channelPicture: scope.channel.offlinePicture,
- channelSound: scope.channel.offlineSoundtrack,
- repeatCooldown : Math.floor(scope.channel.fillerRepeatCooldown / 60000),
- filler: JSON.parse( angular.toJson(scope.channel.fillerCollections.map(unfixFillerCollection) ) ),
- fallback: JSON.parse( angular.toJson(scope.channel.fallback) ),
durationSeconds: duration,
- disableOverlay : scope.channel.disableFillerOverlay,
}
}
scope.addOffline = () => {
@@ -1049,6 +1139,7 @@ module.exports = function ($timeout, $location, dizquetv) {
scope.showRotatedNote = false;
scope.channel.duration = 0
scope.hasFlex = false;
+
for (let i = 0, l = scope.channel.programs.length; i < l; i++) {
scope.channel.programs[i].start = new Date(scope.channel.startTime.valueOf() + scope.channel.duration)
scope.channel.programs[i].$index = i;
@@ -1060,6 +1151,7 @@ module.exports = function ($timeout, $location, dizquetv) {
}
scope.maxSize = Math.max(scope.maxSize, scope.channel.programs.length);
scope.libraryLimit = Math.max(0, scope.maxSize - scope.channel.programs.length );
+ scope.endTime = new Date( scope.channel.startTime.valueOf() + scope.channel.duration );
}
scope.error = {}
scope._onDone = async (channel) => {
@@ -1073,43 +1165,87 @@ module.exports = function ($timeout, $location, dizquetv) {
// validate
var now = new Date()
scope.error.any = true;
- if (typeof channel.number === "undefined" || channel.number === null || channel.number === "")
+
+
+ if (typeof channel.number === "undefined" || channel.number === null || channel.number === "") {
scope.error.number = "Select a channel number"
- else if (channelNumbers.indexOf(parseInt(channel.number, 10)) !== -1 && scope.isNewChannel) // we need the parseInt for indexOf to work properly
+ scope.error.tab = "basic";
+ } else if (channelNumbers.indexOf(parseInt(channel.number, 10)) !== -1 && scope.isNewChannel) { // we need the parseInt for indexOf to work properly
scope.error.number = "Channel number already in use."
- else if (!scope.isNewChannel && channel.number !== scope.beforeEditChannelNumber && channelNumbers.indexOf(parseInt(channel.number, 10)) !== -1)
+ scope.error.tab = "basic";
+ } else if (!scope.isNewChannel && channel.number !== scope.beforeEditChannelNumber && channelNumbers.indexOf(parseInt(channel.number, 10)) !== -1) {
scope.error.number = "Channel number already in use."
- else if (channel.number < 0 || channel.number > 9999)
+ scope.error.tab = "basic";
+ } else if (channel.number < 0 || channel.number > 9999) {
scope.error.name = "Enter a valid number (0-9999)"
- else if (typeof channel.name === "undefined" || channel.name === null || channel.name === "")
+ scope.error.tab = "basic";
+ } else if (typeof channel.name === "undefined" || channel.name === null || channel.name === "") {
scope.error.name = "Enter a channel name."
- else if (channel.icon !== "" && !validURL(channel.icon))
+ scope.error.tab = "basic";
+ } else if (channel.icon !== "" && !validURL(channel.icon)) {
scope.error.icon = "Please enter a valid image URL. Or leave blank."
- else if (channel.overlayIcon && !validURL(channel.icon))
+ scope.error.tab = "basic";
+ } else if (channel.overlayIcon && !validURL(channel.icon)) {
scope.error.icon = "Please enter a valid image URL. Cant overlay an invalid image."
- else if (now < channel.startTime)
+ scope.error.tab = "basic";
+ } else if (now < channel.startTime) {
scope.error.startTime = "Start time must not be set in the future."
- else if (channel.programs.length === 0)
+ scope.error.tab = "programming";
+ } else if (channel.programs.length === 0) {
scope.error.programs = "No programs have been selected. Select at least one program."
- else {
+ scope.error.tab = "programming";
+ } else if ( channel.watermark.enabled && notValidNumber(scope.channel.watermark.width, 0.01,100)) {
+ scope.error.watermark = "Please include a valid watermark width.";
+ scope.error.tab = "ffmpeg";
+ } else if ( channel.watermark.enabled && notValidNumber(scope.channel.watermark.verticalMargin, 0.00,100)) {
+ scope.error.watermark = "Please include a valid watermark vertical margin.";
+ scope.error.tab = "ffmpeg";
+ } else if ( channel.watermark.enabled && notValidNumber(scope.channel.watermark.horizontalMargin, 0.00,100)) {
+ scope.error.watermark = "Please include a valid watermark horizontal margin.";
+ scope.error.tab = "ffmpeg";
+ } else if ( channel.watermark.enabled && (scope.channel.watermark.width + scope.channel.watermark.horizontalMargin > 100.0) ) {
+ scope.error.watermark = "Horizontal margin + width should not exceed 100.";
+ scope.error.tab = "ffmpeg";
+ } else if ( channel.watermark.enabled && notValidNumber(scope.channel.watermark.duration, 0)) {
+ scope.error.watermark = "Please include a valid watermark duration.";
+ scope.error.tab = "ffmpeg";
+ } else if (
+ channel.offlineMode != 'pic'
+ && (channel.fallback.length == 0)
+ ) {
+ scope.error.fallback = 'Either add a fallback clip or change the fallback mode to Picture.';
+ scope.error.tab = "flex";
+ } else {
scope.error.any = false;
for (let i = 0; i < scope.channel.programs.length; i++) {
delete scope.channel.programs[i].$index;
}
try {
+ removeMinuteVersionsOfFields(channel);
let s = angular.toJson(channel);
+ addMinuteVersionsOfFields();
if (s.length > 50*1000*1000) {
scope.error.any = true;
scope.error.programs = "Channel is too large, can't save.";
+ scope.error.tab = "programming";
} else {
- await scope.onDone(JSON.parse(s))
+ let cloned = JSON.parse(s);
+ //clean up some stuff that's only used by the UI:
+ cloned.fillerCollections = cloned.fillerCollections.filter( (f) => { return f.id != 'none'; } );
+ cloned.fillerCollections.forEach( (c) => {
+ delete c.percentage;
+ delete c.options;
+ } );
+ await scope.onDone(cloned)
s = null;
}
} catch(err) {
+ addMinuteVersionsOfFields();
$timeout();
console.error(err);
scope.error.any = true;
scope.error.programs = "Unable to save channel."
+ scope.error.tab = "programming";
}
}
$timeout(() => { scope.error = {} }, 60000)
@@ -1202,6 +1338,28 @@ module.exports = function ($timeout, $location, dizquetv) {
};
scope.loadChannels();
+ scope.setTool = (toolName) => {
+ scope.tool = toolName;
+ }
+
+ scope.hasPrograms = () => {
+ return scope.channel.programs.length > 0;
+ }
+
+ scope.showPlexLibrary = () => {
+ scope.displayPlexLibrary = true;
+ }
+
+ scope.toggleTools = () => {
+ scope.showShuffleOptions = !scope.showShuffleOptions
+ localStorage.setItem("channel-tools", (scope.showShuffleOptions? 'on' : 'off') );
+ }
+
+ scope.toggleToolsDirection = () => {
+ scope.reverseTools = ! scope.reverseTools;
+ localStorage.setItem("channel-tools-position", (scope.reverseTools? 'left' : 'right') );
+ }
+
scope.disablePadding = () => {
return (scope.paddingOption.id==-1) || (2*scope.channel.programs.length > scope.maxSize);
}
@@ -1283,6 +1441,53 @@ module.exports = function ($timeout, $location, dizquetv) {
}
+ scope.openFallbackLibrary = () => {
+ scope.showFallbackPlexLibrary = true
+ }
+
+ scope.importFallback = (selectedPrograms) => {
+ for (let i = 0, l = selectedPrograms.length; i < l && i < 1; i++) {
+ selectedPrograms[i].commercials = []
+ }
+ scope.channel.fallback = [];
+ if (selectedPrograms.length > 0) {
+ scope.channel.fallback = [ selectedPrograms[0] ];
+ }
+ scope.showFallbackPlexLibrary = false;
+ }
+
+ scope.fillerOptions = scope.channel.fillerCollections.map( (f) => {
+ return {
+ id: f.id,
+ name: `(${f.id})`,
+ }
+ });
+
+ scope.slide = {
+ value: -1,
+ options: [
+ {id:-1, description: "Time Amount" },
+ {id: 1 * 60 * 1000, description: "1 minute" },
+ {id: 10 * 60 * 1000, description: "10 minutes" },
+ {id: 15 * 60 * 1000, description: "15 minutes" },
+ {id: 30 * 60 * 1000, description: "30 minutes" },
+ {id: 60 * 60 * 1000, description: "1 hour" },
+ {id: 2 * 60 * 60 * 1000, description: "2 hours" },
+ {id: 4 * 60 * 60 * 1000, description: "4 hours" },
+ {id: 8 * 60 * 60 * 1000, description: "8 hours" },
+ {id:12 * 60 * 60 * 1000, description: "12 hours" },
+ {id:24 * 60 * 60 * 1000, description: "1 day" },
+ {id: 7 * 24 * 60 * 60 * 1000, description: "1 week" },
+ ]
+ }
+
+ scope.resolutionOptions = [
+ { id: "", description: "(Use global setting)" },
+ ];
+ resolutionOptions.get()
+ .forEach( (a) => {
+ scope.resolutionOptions.push(a)
+ } );
scope.nightStartHours = [ { id: -1, description: "Start" } ];
scope.nightEndHours = [ { id: -1, description: "End" } ];
@@ -1298,6 +1503,315 @@ module.exports = function ($timeout, $location, dizquetv) {
}
scope.rerunStartHours = scope.nightStartHours;
scope.paddingMod = 30;
+
+ let fillerOptionsFor = (index) => {
+ let used = {};
+ let added = {};
+ for (let i = 0; i < scope.channel.fillerCollections.length; i++) {
+ if (scope.channel.fillerCollections[i].id != 'none' && i != index) {
+ used[ scope.channel.fillerCollections[i].id ] = true;
+ }
+ }
+ let options = [];
+ for (let i = 0; i < scope.fillerOptions.length; i++) {
+ if ( used[scope.fillerOptions[i].id] !== true) {
+ added[scope.fillerOptions[i].id] = true;
+ options.push( scope.fillerOptions[i] );
+ }
+ }
+ if (scope.channel.fillerCollections[index].id == 'none') {
+ added['none'] = true;
+ options.push( {
+ id: 'none',
+ name: 'Add a filler list...',
+ } );
+ }
+ if ( added[scope.channel.fillerCollections[index].id] !== true ) {
+ options.push( {
+ id: scope.channel.fillerCollections[index].id,
+ name: `[${f.id}]`,
+ } );
+ }
+ return options;
+ }
+
+ scope.refreshFillerStuff = () => {
+ if (typeof(scope.channel.fillerCollections) === 'undefined') {
+ return;
+ }
+ addAddFiller();
+ updatePercentages();
+ refreshIndividualOptions();
+ }
+
+ let updatePercentages = () => {
+ let w = 0;
+ for (let i = 0; i < scope.channel.fillerCollections.length; i++) {
+ if (scope.channel.fillerCollections[i].id !== 'none') {
+ w += scope.channel.fillerCollections[i].weight;
+ }
+ }
+ for (let i = 0; i < scope.channel.fillerCollections.length; i++) {
+ if (scope.channel.fillerCollections[i].id !== 'none') {
+ scope.channel.fillerCollections[i].percentage = (scope.channel.fillerCollections[i].weight * 100 / w).toFixed(2) + "%";
+ }
+ }
+
+ };
+
+
+ let addAddFiller = () => {
+ if ( (scope.channel.fillerCollections.length == 0) || (scope.channel.fillerCollections[scope.channel.fillerCollections.length-1].id !== 'none') ) {
+ scope.channel.fillerCollections.push ( {
+ 'id': 'none',
+ 'weight': 300,
+ 'cooldown': 0,
+ } );
+ }
+ }
+
+
+ let refreshIndividualOptions = () => {
+ for (let i = 0; i < scope.channel.fillerCollections.length; i++) {
+ scope.channel.fillerCollections[i].options = fillerOptionsFor(i);
+ }
+ }
+
+ let refreshFillerOptions = async() => {
+
+ try {
+ let r = await dizquetv.getAllFillersInfo();
+ scope.fillerOptions = r.map( (f) => {
+ return {
+ id: f.id,
+ name: f.name,
+ };
+ } );
+ scope.refreshFillerStuff();
+ scope.$apply();
+ } catch(err) {
+ console.error("Unable to get filler info", err);
+ }
+ };
+ scope.refreshFillerStuff();
+ refreshFillerOptions();
+
+ function parseResolutionString(s) {
+ var i = s.indexOf('x');
+ if (i == -1) {
+ i = s.indexOf("×");
+ if (i == -1) {
+ return {w:1920, h:1080}
+ }
+ }
+ return {
+ w: parseInt( s.substring(0,i) , 10 ),
+ h: parseInt( s.substring(i+1) , 10 ),
+ }
+ }
+
+ scope.videoRateDefault = "(Use global setting)";
+ scope.videoBufSizeDefault = "(Use global setting)";
+
+ let refreshScreenResolution = async () => {
+
+
+ try {
+ let ffmpegSettings = await dizquetv.getFfmpegSettings()
+ if (
+ (ffmpegSettings.targetResolution != null)
+ && (typeof(ffmpegSettings.targetResolution) !== 'undefined')
+ && (typeof(ffmpegSettings.targetResolution) !== '')
+ ) {
+ let p = parseResolutionString( ffmpegSettings.targetResolution );
+ scope.resolutionOptions[0] = {
+ id: "",
+ description: `Use global setting (${ffmpegSettings.targetResolution})`,
+ }
+ ffmpegSettings.targetResolution
+ scope.screenW = p.w;
+ scope.screenH = p.h;
+ scope.videoRateDefault = `global setting=${ffmpegSettings.videoBitrate}`;
+ scope.videoBufSizeDefault = `global setting=${ffmpegSettings.videoBufSize}`;
+
+ $timeout();
+ }
+ } catch(err) {
+ console.error("Could not fetch ffmpeg settings", err);
+ }
+ }
+ refreshScreenResolution();
+
+ scope.showList = () => {
+ return ! scope.showFallbackPlexLibrary;
+ }
+
+
+ scope.deleteFillerList =(index) => {
+ scope.channel.fillerCollections.splice(index, 1);
+ scope.refreshFillerStuff();
+ }
+
+
+
+ scope.durationString = (duration) => {
+ var date = new Date(0);
+ date.setSeconds( Math.floor(duration / 1000) ); // specify value for SECONDS here
+ return date.toISOString().substr(11, 8);
+ }
+
+ scope.getCurrentWH = () => {
+ if (scope.channel.transcoding.targetResolution !== '') {
+ return parseResolutionString( scope.channel.transcoding.targetResolution );
+ }
+ return {
+ w: scope.screenW,
+ h: scope.screenH
+ }
+ }
+ scope.getWatermarkPreviewOuter = () => {
+ let tm = scope.getCurrentWH();
+ let resolutionW = tm.w;
+ let resolutionH = tm.h;
+ let width = 100;
+ let height = width / ( resolutionW / resolutionH );
+
+
+ return {
+ width: `${width}%`,
+ "overflow" : "hidden",
+ "padding-top": 0,
+ "padding-left": 0,
+ "padding-right": 0,
+ "padding-bottom": `${height}%`,
+ position: "relative",
+ }
+ }
+
+ scope.getWatermarkPreviewRectangle = (p,q) => {
+ let s = scope.getCurrentWH();
+ if ( (s.w*q) == (s.h*p) ) {
+ //not necessary, hide it
+ return {
+ position: "absolute",
+ visibility: "hidden",
+ }
+ } else {
+ //assume width is equal
+ // s.w / h2 = p / q
+ let h2 = (s.w * q * 100) / (p * s.h);
+ let w2 = 100;
+ let left = undefined;
+ let top = undefined;
+ if (h2 > 100) {
+ //wrong
+ //the other way around
+ w2 = (s.h / s.w) * p * 100 / q;
+ left = (100 - w2) / 2;
+ } else {
+ top = (100 - h2) / 2;
+ }
+ let padding = (100 * q) / p;
+ return {
+ "width" : `${w2}%`,
+ "padding-top": "0",
+ "padding-left": "0",
+ "padding-right": "0",
+ "padding-bottom": `${padding}%`,
+ "margin" : "0",
+ "left": `${left}%`,
+ "top" : `${top}%`,
+ "position": "absolute",
+ }
+ }
+
+ }
+
+ scope.getWatermarkSrc = () => {
+ let url = scope.channel.watermark.url;
+ if ( url == null || typeof(url) == 'undefined' || url == '') {
+ url = scope.channel.icon;
+ }
+ return url;
+ }
+
+ scope.getWatermarkPreviewInner = () => {
+ let width = Math.max(Math.min(100, scope.channel.watermark.width), 0);
+ let res = {
+ width: `${width}%`,
+ margin: "0",
+ position: "absolute",
+ }
+ if (scope.channel.watermark.fixedSize === true) {
+ delete res.width;
+ }
+ let mH = scope.channel.watermark.horizontalMargin;
+ let mV = scope.channel.watermark.verticalMargin;
+ if (scope.channel.watermark.position == 'top-left') {
+ res["top"] = `${mV}%`;
+ res["left"] = `${mH}%`;
+ } else if (scope.channel.watermark.position == 'top-right') {
+ res["top"] = `${mV}%`;
+ res["right"] = `${mH}%`;
+ } else if (scope.channel.watermark.position == 'bottom-right') {
+ res["bottom"] = `${mV}%`;
+ res["right"] = `${mH}%`;
+ } else if (scope.channel.watermark.position == 'bottom-left') {
+ res["bottom"] = `${mV}%`;
+ res["left"] = `${mH}%`;
+ } else {
+ console.log("huh? " + scope.channel.watermark.position );
+ }
+ return res;
+ }
+
+ function notValidNumber(x, lower, upper) {
+ if ( (x == null) || (typeof(x) === 'undefined') || isNaN(x) ) {
+ return true;
+ }
+ if ( (typeof(lower) !== 'undefined') && (x < lower) ) {
+ return true;
+ }
+ if ( (typeof(upper) !== 'undefined') && (x > upper) ) {
+ return true;
+ }
+ return false;
+ }
+
+
+ scope.onTimeSlotsDone = (slotsResult) => {
+ scope.channel.programs = slotsResult.programs;
+
+ let t = (new Date()).getTime();
+ let t1 =new Date( (new Date( slotsResult.startTime ) ).getTime() );
+ let total = 0;
+ for (let i = 0; i < slotsResult.programs.length; i++) {
+ total += slotsResult.programs[i].duration;
+ }
+
+ scope.channel.scheduleBackup = slotsResult.schedule;
+
+ while(t1 > t) {
+ //TODO: Replace with division
+ t1 -= total;
+ }
+ scope.channel.startTime = new Date(t1);
+ adjustStartTimeToCurrentProgram();
+ updateChannelDuration();
+ }
+ scope.onTimeSlotsButtonClick = () => {
+ scope.timeSlots.startDialog(scope.channel.programs, scope.maxSize, scope.channel.scheduleBackup );
+ }
+
+ },
+
+ pre: function(scope) {
+ scope.timeSlots = null;
+ scope.registerTimeSlots = (timeSlots) => {
+ scope.timeSlots = timeSlots;
+ }
+ },
+
}
}
}
diff --git a/web/directives/ffmpeg-settings.js b/web/directives/ffmpeg-settings.js
index 23dfdc4..6388b55 100644
--- a/web/directives/ffmpeg-settings.js
+++ b/web/directives/ffmpeg-settings.js
@@ -1,4 +1,4 @@
- module.exports = function (dizquetv) {
+module.exports = function (dizquetv, resolutionOptions) {
return {
restrict: 'E',
templateUrl: 'templates/ffmpeg-settings.html',
@@ -6,6 +6,7 @@
scope: {
},
link: function (scope, element, attrs) {
+ //add validations to ffmpeg settings, speciall commas in codec name
dizquetv.getFfmpegSettings().then((settings) => {
scope.settings = settings
})
@@ -25,18 +26,7 @@
scope.hideIfNotAutoPlay = () => {
return scope.settings.enableAutoPlay != true
};
- scope.resolutionOptions=[
- {id:"420x420",description:"420x420 (1:1)"},
- {id:"480x270",description:"480x270 (HD1080/16 16:9)"},
- {id:"576x320",description:"576x320 (18:10)"},
- {id:"640x360",description:"640x360 (nHD 16:9)"},
- {id:"720x480",description:"720x480 (WVGA 3:2)"},
- {id:"800x600",description:"800x600 (SVGA 4:3)"},
- {id:"1024x768",description:"1024x768 (WXGA 4:3)"},
- {id:"1280x720",description:"1280x720 (HD 16:9)"},
- {id:"1920x1080",description:"1920x1080 (FHD 16:9)"},
- {id:"3840x2160",description:"3840x2160 (4K 16:9)"},
- ];
+ scope.resolutionOptions= resolutionOptions.get();
scope.muxDelayOptions=[
{id:"0",description:"0 Seconds"},
{id:"1",description:"1 Seconds"},
@@ -59,6 +49,17 @@
{value:"sine", description:"Beep"},
{value:"silent", description:"No Audio"},
]
+ scope.fpsOptions = [
+ {id: 23.976, description: "23.976 frames per second"},
+ {id: 24, description: "24 frames per second"},
+ {id: 25, description: "25 frames per second"},
+ {id: 29.97, description: "29.97 frames per second"},
+ {id: 30, description: "30 frames per second"},
+ {id: 50, description: "50 frames per second"},
+ {id: 59.94, description: "59.94 frames per second"},
+ {id: 60, description: "60 frames per second"},
+ {id: 120, description: "120 frames per second"},
+ ];
}
}
}
\ No newline at end of file
diff --git a/web/directives/flex-config.js b/web/directives/flex-config.js
index ba0a6e0..0009264 100644
--- a/web/directives/flex-config.js
+++ b/web/directives/flex-config.js
@@ -1,5 +1,3 @@
-const dizquetv = require("../services/dizquetv");
-
module.exports = function ($timeout, dizquetv) {
return {
restrict: 'E',
@@ -12,7 +10,6 @@ module.exports = function ($timeout, dizquetv) {
onDone: "=onDone"
},
link: function (scope, element, attrs) {
- scope.fillerOptions = [];
let updateNext = true;
scope.$watch('program', () => {
try {
@@ -23,126 +20,14 @@ module.exports = function ($timeout, dizquetv) {
return;
}
updateNext = false;
- let filler = scope.program.filler;
- if (typeof(filler) === 'undefined') {
- filler = [];
- }
- scope.program.filler = filler;
- scope.showFallbackPlexLibrary = false;
- scope.fillerOptions = filler.map( (f) => {
- return {
- id: f.id,
- name: `(${f.id})`,
- }
- });
-
- $timeout( () => {
- refreshFillerOptions();
- }, 0);
- } catch(err) {
- console.error("$watch error", err);
+ scope.error = null;
+ } catch (err) {
+ console.error(err);
}
})
- let fillerOptionsFor = (index) => {
- let used = {};
- let added = {};
- for (let i = 0; i < scope.program.filler.length; i++) {
- if (scope.program.filler[i].id != 'none' && i != index) {
- used[ scope.program.filler[i].id ] = true;
- }
- }
- let options = [];
- for (let i = 0; i < scope.fillerOptions.length; i++) {
- if ( used[scope.fillerOptions[i].id] !== true) {
- added[scope.fillerOptions[i].id] = true;
- options.push( scope.fillerOptions[i] );
- }
- }
- if (scope.program.filler[index].id == 'none') {
- added['none'] = true;
- options.push( {
- id: 'none',
- name: 'Add a filler list...',
- } );
- }
- if ( added[scope.program.filler[index].id] !== true ) {
- options.push( {
- id: scope.program.filler[index].id,
- name: `[${f.id}]`,
- } );
- }
- return options;
- }
-
- scope.refreshFillerStuff = () => {
- if (typeof(scope.program) === 'undefined') {
- return;
- }
- addAddFiller();
- updatePercentages();
- refreshIndividualOptions();
- }
-
- let updatePercentages = () => {
- let w = 0;
- for (let i = 0; i < scope.program.filler.length; i++) {
- if (scope.program.filler[i].id !== 'none') {
- w += scope.program.filler[i].weight;
- }
- }
- for (let i = 0; i < scope.program.filler.length; i++) {
- if (scope.program.filler[i].id !== 'none') {
- scope.program.filler[i].percentage = (scope.program.filler[i].weight * 100 / w).toFixed(2) + "%";
- }
- }
-
- };
-
-
- let addAddFiller = () => {
- if ( (scope.program.filler.length == 0) || (scope.program.filler[scope.program.filler.length-1].id !== 'none') ) {
- scope.program.filler.push ( {
- 'id': 'none',
- 'weight': 300,
- 'cooldown': 0,
- } );
- }
- }
-
-
- let refreshIndividualOptions = () => {
- for (let i = 0; i < scope.program.filler.length; i++) {
- scope.program.filler[i].options = fillerOptionsFor(i);
- }
- }
-
- let refreshFillerOptions = async() => {
-
- try {
- let r = await dizquetv.getAllFillersInfo();
- scope.fillerOptions = r.map( (f) => {
- return {
- id: f.id,
- name: f.name,
- };
- } );
- scope.refreshFillerStuff();
- scope.$apply();
- } catch(err) {
- console.error("Unable to get filler info", err);
- }
- };
- scope.refreshFillerStuff();
- refreshFillerOptions();
-
scope.finished = (prog) => {
- if (
- prog.channelOfflineMode != 'pic'
- && (prog.fallback.length == 0)
- ) {
- scope.error = { fallback: 'Either add a fallback clip or change the fallback mode to Picture.' }
- }
+ scope.error = null;
if (isNaN(prog.durationSeconds) || prog.durationSeconds < 0 ) {
scope.error = { duration: 'Duration must be a positive integer' }
}
@@ -152,37 +37,9 @@ module.exports = function ($timeout, dizquetv) {
}, 30000)
return
}
- prog.filler = prog.filler.filter( (f) => { return f.id != 'none'; } );
scope.onDone(JSON.parse(angular.toJson(prog)))
scope.program = null
}
- scope.showList = () => {
- return ! scope.showFallbackPlexLibrary;
- }
- scope.importFallback = (selectedPrograms) => {
- for (let i = 0, l = selectedPrograms.length; i < l && i < 1; i++) {
- selectedPrograms[i].commercials = []
- }
- scope.program.fallback = [];
- if (selectedPrograms.length > 0) {
- scope.program.fallback = [ selectedPrograms[0] ];
- }
- scope.showFallbackPlexLibrary = false;
- }
-
-
- scope.deleteFillerList =(index) => {
- scope.program.filler.splice(index, 1);
- scope.refreshFillerStuff();
- }
-
-
-
- scope.durationString = (duration) => {
- var date = new Date(0);
- date.setSeconds( Math.floor(duration / 1000) ); // specify value for SECONDS here
- return date.toISOString().substr(11, 8);
- }
}
};
diff --git a/web/directives/time-slots-schedule-editor.js b/web/directives/time-slots-schedule-editor.js
new file mode 100644
index 0000000..6a11334
--- /dev/null
+++ b/web/directives/time-slots-schedule-editor.js
@@ -0,0 +1,268 @@
+
+
+module.exports = function ($timeout, dizquetv) {
+ return {
+ restrict: 'E',
+ templateUrl: 'templates/time-slots-schedule-editor.html',
+ replace: true,
+ scope: {
+ linker: "=linker",
+ onDone: "=onDone"
+ },
+
+ link: function (scope, element, attrs) {
+ scope.limit = 50000;
+ scope.visible = false;
+ scope.fake = { time: -1 };
+ scope.timeOptions = []
+ scope.badTimes = false;
+ let showsById;
+ let shows;
+
+
+ function reset() {
+ showsById = {};
+ shows = [];
+ scope.schedule = {
+ lateness : 0,
+ maxDays: 365,
+ slots : [],
+ pad: 1,
+ fake: { time: -1 },
+ }
+
+ }
+ reset();
+
+ function loadBackup(backup) {
+ scope.schedule = JSON.parse( JSON.stringify(backup) );
+ if (typeof(scope.schedule.pad) == 'undefined') {
+ scope.schedule.pad = 1;
+ }
+ let slots = scope.schedule.slots;
+ for (let i = 0; i < slots.length; i++) {
+ let found = false;
+ for (let j = 0; j < scope.showOptions.length; j++) {
+ if (slots[i].showId == scope.showOptions[j].id) {
+ found = true;
+ }
+ }
+ if (! found) {
+ slots[i].showId = "flex.";
+ slots[i].order = "shuffle";
+ }
+ }
+ scope.schedule.fake = {
+ time: -1,
+ }
+ }
+
+ for (let h = 0; h < 24; h++) {
+ for (let m = 0; m < 60; m += 15) {
+ scope.timeOptions.push( {
+ id: (h * 60 + m) * 60 * 1000,
+ description: niceLookingTime(h,m),
+ } );
+ }
+ }
+ scope.latenessOptions = [
+ { id: 0 , description: "Do not allow" },
+ { id: 5*60*1000, description: "5 minutes" },
+ { id: 10*60*1000 , description: "10 minutes" },
+ { id: 15*60*1000 , description: "15 minutes" },
+ { id: 1*60*60*1000 , description: "1 hour" },
+ ];
+ scope.fakeTimeOptions = JSON.parse( JSON.stringify( scope.timeOptions ) );
+ scope.fakeTimeOptions.push( {id: -1, description: "Add slot"} );
+
+ scope.padOptions = [
+ {id: 1, description: "Do not pad" },
+ {id: 5*60*1000, description: "0:00, 0:05, 0:10, ..., 0:55" },
+ {id: 15*60*1000, description: "0:00, 0:15, 0:30, ..., 0:45" },
+ {id: 30*60*1000, description: "0:00, 0:30" },
+ {id: 1*60*60*1000, description: "0:00" },
+ ];
+
+ scope.showOptions = [];
+ scope.orderOptions = [
+ { id: "next", description: "Play Next" },
+ { id: "shuffle", description: "Shuffle" },
+ ];
+
+ let doIt = async() => {
+ scope.schedule.timeZoneOffset = (new Date()).getTimezoneOffset();
+ let res = await dizquetv.calculateTimeSlots(scope.programs, scope.schedule );
+ res.schedule = scope.schedule;
+ delete res.schedule.fake;
+ return res;
+ }
+
+
+
+
+ let startDialog = (programs, limit, backup) => {
+ scope.limit = limit;
+ scope.programs = programs;
+
+ reset();
+
+
+
+ programs.forEach( (p) => {
+ let show = getShow(p);
+ if (show != null) {
+ if (typeof(showsById[show.id]) === 'undefined') {
+ showsById[show.id] = shows.length;
+ shows.push( show );
+ } else {
+ show = shows[ showsById[show.id] ];
+ }
+ }
+ } );
+ scope.showOptions = shows.map( (show) => { return show } );
+ scope.showOptions.push( {
+ id: "flex.",
+ description: "Flex",
+ } );
+ if (typeof(backup) !== 'undefined') {
+ loadBackup(backup);
+ }
+
+ scope.visible = true;
+ }
+
+
+ scope.linker( {
+ startDialog: startDialog,
+ } );
+
+ scope.finished = async (cancel) => {
+ if (!cancel) {
+ try {
+ scope.loading = true;
+ $timeout();
+ scope.onDone( await doIt() );
+ scope.visible = false;
+ } catch(err) {
+ console.error("Unable to generate channel lineup", err);
+ scope.error = "There was an error processing the schedule";
+ return;
+ } finally {
+ scope.loading = false;
+ $timeout();
+ }
+ } else {
+ scope.visible = false;
+ }
+ }
+
+ scope.fakeTimeChanged = () => {
+
+ if (scope.fake.time != -1) {
+ scope.schedule.slots.push( {
+ time: scope.fake.time,
+ showId: "flex.",
+ order: "next"
+ } )
+ scope.fake.time = -1;
+ scope.refreshSlots();
+ }
+ }
+
+ scope.deleteSlot = (index) => {
+ scope.schedule.slots.splice(index, 1);
+ }
+
+ scope.hasTimeError = (slot) => {
+ return typeof(slot.timeError) !== 'undefined';
+ }
+
+ scope.disableCreateLineup = () => {
+ if (scope.badTimes) {
+ return true;
+ }
+ if (typeof(scope.schedule.maxDays) === 'undefined') {
+ return true;
+ }
+ if (scope.schedule.slots.length == 0) {
+ return true;
+ }
+ return false;
+ }
+
+ scope.canShowSlot = (slot) => {
+ return (slot.showId != 'flex.') && !(slot.showId.startsWith('redirect.'));
+ }
+
+ scope.refreshSlots = () => {
+ scope.badTimes = false;
+ //"Bubble sort ought to be enough for anybody"
+ for (let i = 0; i < scope.schedule.slots.length; i++) {
+ for (let j = i+1; j < scope.schedule.slots.length; j++) {
+ if (scope.schedule.slots[j].time< scope.schedule.slots[i].time) {
+ let x = scope.schedule.slots[i];
+ scope.schedule.slots[i] = scope.schedule.slots[j];
+ scope.schedule.slots[j] = x;
+ }
+ }
+ if (scope.schedule.slots[i].showId == 'movie.') {
+ scope.schedule.slots[i].order = "shuffle";
+ }
+ }
+ for (let i = 0; i < scope.schedule.slots.length; i++) {
+ if (
+ (i > 0 && (scope.schedule.slots[i].time == (scope.schedule.slots[i-1].time) ) )
+ || ( (i+1 < scope.schedule.slots.length) && (scope.schedule.slots[i].time == (scope.schedule.slots[i+1].time) ) )
+ ) {
+ scope.badTimes = true;
+ scope.schedule.slots[i].timeError = "Please select a unique time.";
+ } else {
+ delete scope.schedule.slots[i].timeError;
+ }
+ }
+ $timeout();
+ }
+
+
+
+ }
+ };
+}
+
+function niceLookingTime(h, m) {
+ let d = new Date();
+ d.setHours(h);
+ d.setMinutes(m);
+ d.setSeconds(0);
+ d.setMilliseconds(0);
+
+ return d.toLocaleTimeString();
+}
+
+//This is a duplicate code, but maybe it doesn't have to be?
+function getShow(program) {
+ //used for equalize and frequency tweak
+ if (program.isOffline) {
+ if (program.type == 'redirect') {
+ return {
+ description : `Redirect to channel ${program.channel}`,
+ id: "redirect." + program.channel,
+ channel: program.channel,
+ }
+ } else {
+ return null;
+ }
+ } else if ( (program.type == 'episode') && ( typeof(program.showTitle) !== 'undefined' ) ) {
+ return {
+ description: program.showTitle,
+ id: "tv." + program.showTitle,
+ }
+ } else {
+ return {
+ description: "Movies",
+ id: "movie.",
+ }
+ }
+}
+
+
diff --git a/web/public/style.css b/web/public/style.css
index 266cd05..0d75f8c 100644
--- a/web/public/style.css
+++ b/web/public/style.css
@@ -1,5 +1,10 @@
.pull-right { float: right; }
+.modal-semi-body {
+ padding: 1rem;
+ flex: 1 1 auto;
+}
+
.commercials-panel {
background-color: rgb(70, 70, 70);
border-top: 1px solid #daa104;
@@ -249,6 +254,45 @@ table.tvguide {
text-align: right;
}
+div.channel-tools {
+ max-height: 20em;
+ overflow-y: scroll;
+ overflow-x: hidden;
+ margin-bottom: 1.5rem;
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+ border-top: 1px solid #888;
+ border-bottom: 1px solid #888;
+}
+div.channel-tools p {
+ font-size: 0.5rem;
+ margin-top: 0.01rem;
+}
+
+div.programming-panes {
+ padding-top: 0;
+ padding-bottom: 0;
+}
+div.programming-panes div.reverse {
+ flex-direction: row-reverse;
+}
+div.programming-panes div.programming-pane {
+ max-height: 30rem;
+ overflow-y: auto;
+ padding-top: 0;
+ padding-bottom: 0;
+ padding-left: 1.5rem;
+ padding-right: 1.5rem;
+}
+
+div.programming-programs div.list-group-item {
+ height: 1.5rem;
+}
+.channel-editor-modal-big {
+ width:1200px;
+ min-width: 98%;
+}
+
/* Safari */
@-webkit-keyframes spin {
0% { -webkit-transform: rotate(0deg); }
@@ -260,3 +304,45 @@ table.tvguide {
100% { transform: rotate(360deg); }
}
+
+.program-row:nth-child(odd), .filler-row:nth-child(odd), .channel-row:nth-child(odd) {
+ background-color: #eeeeee;
+}
+
+.tools-pane button {
+ text-overflow: ellipsis;
+ overflow: hidden;
+}
+
+.tools-pane button:not(.btn-danger),
+.tools-pane .input-group-text,
+.tools-pane select {
+ border: 1px solid #999999 !important;
+}
+.tools-pane input,
+.tools-pane select {
+ font-size: 14px;
+}
+.tools-pane select {
+ text-align: center;
+ border-radius: 0;
+ padding: 0 16px 0 0;
+ height: initial;
+}
+.tools-pane select:first-of-type {
+ border-top-left-radius: 3px;
+ border-bottom-left-radius: 3px;
+}
+.tools-pane .input-group-prepend + button {
+ border-left: 0;
+}
+.tools-pane input.form-control {
+ border-color: #999999;
+}
+.watermark-preview {
+ background: linear-gradient(180deg, rgb(90, 90, 90) 0%, rgb(110, 110, 110) 35%, rgb(130, 130, 130) 100%, rgb(150, 150, 150) 100%);
+ border: 2px solid black;
+}
+.watermark-preview .alternate-aspect {
+ background : rgba(255,255,255, 0.1);
+}
\ No newline at end of file
diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html
index f6fdc75..1105c54 100644
--- a/web/public/templates/channel-config.html
+++ b/web/public/templates/channel-config.html
@@ -1,13 +1,26 @@