Merge branch 'dev/1.2.x' into edge

This commit is contained in:
vexorian 2020-11-22 21:46:54 -04:00
commit 3762f032d3
16 changed files with 389 additions and 88 deletions

View File

@ -1,4 +1,4 @@
# dizqueTV 1.2.1
# dizqueTV 1.2.2-prerelease
![Discord](https://img.shields.io/discord/711313431457693727?logo=discord&logoColor=fff&style=flat-square) ![GitHub top language](https://img.shields.io/github/languages/top/vexorian/dizquetv?logo=github&style=flat-square) ![Docker Pulls](https://img.shields.io/docker/pulls/vexorian/dizquetv?logo=docker&logoColor=fff&style=flat-square)
Create live TV channel streams from media on your Plex servers.

View File

@ -17,6 +17,7 @@ const constants = require('./src/constants')
const ChannelDB = require("./src/dao/channel-db");
const FillerDB = require("./src/dao/filler-db");
const TVGuideService = require("./src/tv-guide-service");
const onShutdown = require("node-graceful-shutdown").onShutdown;
console.log(
` \\
@ -209,3 +210,11 @@ function initDB(db, channelDB) {
}
}
onShutdown("log" , [], async() => {
console.log("Received exit signal, attempting graceful shutdonw...");
});
onShutdown("xmltv-writer" , [], async() => {
await xmltv.shutdown();
} );

View File

@ -19,6 +19,7 @@
"angular": "^1.7.9",
"angular-router-browserify": "0.0.2",
"angular-vs-repeat": "2.0.13",
"random-js" : "2.1.0",
"axios": "^0.19.2",
"body-parser": "^1.19.0",
"diskdb": "^0.1.17",
@ -26,6 +27,7 @@
"node-ssdp": "^4.0.0",
"request": "^2.88.2",
"uuid": "^8.0.0",
"node-graceful-shutdown" : "1.1.0",
"xml-writer": "^1.7.0"
},
"bin": "dist/index.js",

View File

@ -5,5 +5,5 @@ module.exports = {
TVGUIDE_MAXIMUM_FLEX_DURATION : 6 * 60 * 60 * 1000,
TOO_FREQUENT: 100,
VERSION_NAME: "1.2.1"
VERSION_NAME: "1.2.2-prerelease"
}

View File

@ -6,6 +6,11 @@ module.exports = {
let channelCache = require('./channel-cache');
const SLACK = require('./constants').SLACK;
const randomJS = require("random-js");
const Random = randomJS.Random;
const random = new Random( randomJS.MersenneTwister19937.autoSeed() );
module.exports.random = random;
function getCurrentProgramAndTimeElapsed(date, channel) {
let channelStartTime = (new Date(channel.startTime)).getTime();
@ -102,7 +107,7 @@ function createLineup(obj, channel, fillers, isFirst) {
//it's boring and odd to tune into a channel and it's always
//the start of a commercial.
let more = Math.max(0, filler.duration - fillerstart - 15000 - SLACK);
fillerstart += Math.floor(more * Math.random() );
fillerstart += random.integer(0, more);
}
lineup.push({ // just add the video, starting at 0, playing the entire duration
type: 'commercial',
@ -152,12 +157,7 @@ function createLineup(obj, channel, fillers, isFirst) {
}
function weighedPick(a, total) {
if (a==total) {
return true;
} else {
let ran = Math.random();
return ran * total < a;
}
return random.bool(a, total);
}
function pickRandomWithMaxDuration(channel, fillers, maxDuration) {
@ -293,3 +293,4 @@ function getWatermark( ffmpegSettings, channel, type) {
}
return result;
}

View File

@ -182,7 +182,7 @@ lang=en`
try {
return this.getVideoStats().videoDecision === "copy";
} catch (e) {
console.log("Error at decision:" + e);
console.log("Error at decision:", e);
return false;
}
}
@ -199,7 +199,7 @@ lang=en`
try {
return this.getVideoStats().videoDecision === "copy" && this.getVideoStats().audioDecision === "copy";
} catch (e) {
console.log("Error at decision:" + e);
console.log("Error at decision:" , e);
return false;
}
}
@ -245,7 +245,7 @@ lang=en`
}
}.bind(this) )
} catch (e) {
console.log("Error at decision:" + e);
console.log("Error at decision:" , e);
}
this.log("Current video stats:")
@ -289,7 +289,11 @@ lang=en`
}
async getDecision(directPlay) {
async getDecisionUnmanaged(directPlay) {
if (this.settings.streamPath === 'direct') {
console.log("Skip get transcode decision because direct path is enabled");
return;
}
let res = await axios.get(`${this.server.uri}/video/:/transcode/universal/decision?${this.transcodingArgs}`, {
headers: { Accept: 'application/json' }
})
@ -307,6 +311,14 @@ lang=en`
}
}
async getDecision(directPlay) {
try {
await this.getDecisionUnmanaged(directPlay);
} catch (err) {
console.error(err);
}
}
getStatusUrl() {
let profileName=`Generic`;

View File

@ -1,10 +1,13 @@
const constants = require("../constants");
const random = require('../helperFuncs').random;
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
@ -39,7 +42,7 @@ function shuffle(array, lo, hi ) {
}
let currentIndex = hi, temporaryValue, randomIndex
while (lo !== currentIndex) {
randomIndex = lo + Math.floor(Math.random() * (currentIndex -lo) );
randomIndex = random.integer(lo, currentIndex-1);
currentIndex -= 1
temporaryValue = array[currentIndex]
array[currentIndex] = array[randomIndex]
@ -208,6 +211,13 @@ module.exports = async( programs, schedule ) => {
if (typeof(schedule.maxDays) == 'undefined') {
return { userError: "schedule.maxDays must be defined." };
}
if (typeof(schedule.flexPreference) === 'undefined') {
schedule.flexPreference = "distribute";
}
if (schedule.flexPreference !== "distribute" && schedule.flexPreference !== "end") {
return { userError: `Invalid schedule.flexPreference value: "${schedule.flexPreference}"` };
}
let flexBetween = ( schedule.flexPreference !== "end" );
// throttle so that the stream is not affected negatively
let steps = 0;
@ -222,6 +232,8 @@ module.exports = async( programs, schedule ) => {
let shows = [];
function getNextForSlot(slot, remaining) {
//remaining doesn't restrict what next show is picked. It is only used
//for shows with flexible length (flex and redirects)
if (slot.showId === "flex.") {
return {
isOffline: true,
@ -255,6 +267,21 @@ module.exports = async( programs, schedule ) => {
}
}
function makePadded(item) {
let x = item.duration;
let m = x % schedule.pad;
let f = 0;
if ( (m > constants.SLACK) && (schedule.pad - m > constants.SLACK) ) {
f = schedule.pad - m;
}
return {
item: item,
pad: f,
totalDuration: item.duration + f,
}
}
// load the programs
for (let i = 0; i < programs.length; i++) {
let p = programs[i];
@ -282,7 +309,7 @@ module.exports = async( programs, schedule ) => {
let t0 = d.getTime();
let p = [];
let t = t0;
let previous = null;
let wantedFinish = t % DAY;
let hardLimit = t0 + schedule.maxDays * DAY;
let pushFlex = (d) => {
@ -299,11 +326,19 @@ module.exports = async( programs, schedule ) => {
}
}
for (let i = 0; i < LIMIT; i++) {
while ( (t < hardLimit) && (p.length < LIMIT) ) {
await throttle();
//ensure t is padded
let m = t % schedule.pad;
if ( (t % schedule.pad > constants.SLACK) && (schedule.pad - m > constants.SLACK) ) {
pushFlex( schedule.pad - m );
continue;
}
let dayTime = t % DAY;
let slot = null;
let remaining = null;
let late = null;
for (let i = 0; i < s.length; i++) {
let endTime;
if (i == s.length - 1) {
@ -315,62 +350,109 @@ module.exports = async( programs, schedule ) => {
if ((s[i].time <= dayTime) && (dayTime < endTime)) {
slot = s[i];
remaining = endTime - dayTime;
late = dayTime - s[i].time;
break;
}
if ((s[i].time <= dayTime + DAY) && (dayTime + DAY < endTime)) {
slot = s[i];
dayTime += DAY;
remaining = endTime - dayTime;
late = dayTime + DAY - s[i].time;
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);
if (late >= schedule.lateness + constants.SLACK ) {
//it's late.
item = {
isOffline : true,
duration: remaining,
}
}
if (item.isOffline) {
//flex or redirect. We can just use the whole duration
p.push(item);
t += remaining;
continue;
}
if (item.duration > remaining) {
// Slide
p.push(item);
t += item.duration;
advanceSlot(slot);
continue;
}
let padded = makePadded(item);
let total = padded.totalDuration;
advanceSlot(slot);
let pads = [ padded ];
while(true) {
let item2 = getNextForSlot(slot);
if (total + item2.duration > remaining) {
break;
}
let padded2 = makePadded(item2);
pads.push(padded2);
advanceSlot(slot);
total += padded2.totalDuration;
}
let rem = Math.max(0, remaining - total);
if (flexBetween) {
let div = Math.floor(rem / schedule.pad );
let mod = rem % schedule.pad;
// add mod to the latest item
pads[ pads.length - 1].pad += mod;
pads[ pads.length - 1].totalDuration += mod;
let sortedPads = pads.map( (p, $index) => {
return {
pad: p.pad,
index : $index,
}
});
sortedPads.sort( (a,b) => { return a.pad - b.pad; } );
for (let i = 0; i < pads.length; i++) {
let q = Math.floor( div / pads.length );
if (i < div % pads.length) {
q++;
}
let j = sortedPads[i].index;
pads[j].pad += q * schedule.pad;
}
} else {
//also add div to the latest item
pads[ pads.length - 1].pad += rem;
pads[ pads.length - 1].totalDuration += rem;
}
// now unroll them all
for (let i = 0; i < pads.length; i++) {
p.push( pads[i].item );
t += pads[i].item.duration;
pushFlex( pads[i].pad );
}
}
while ( (t > hardLimit) || (p.length >= LIMIT) ) {
t -= p.pop().duration;
}
let m = t % DAY;
let rem = 0;
if (m > wantedFinish) {
rem = DAY + wantedFinish - m;
} else if (m < wantedFinish) {
rem = wantedFinish - m;
}
if (rem > constants.SLACK) {
pushFlex(rem);
}
return {
programs: p,

View File

@ -1,11 +1,29 @@
const XMLWriter = require('xml-writer')
const fs = require('fs')
const helperFuncs = require('./helperFuncs')
const constants = require('./constants')
module.exports = { WriteXMLTV: WriteXMLTV }
module.exports = { WriteXMLTV: WriteXMLTV, shutdown: shutdown }
function WriteXMLTV(json, xmlSettings, throttle ) {
let isShutdown = false;
let isWorking = false;
async function WriteXMLTV(json, xmlSettings, throttle ) {
if (isShutdown) {
return;
}
if (isWorking) {
console.log("Concurrent xmltv write attempt detected, skipping");
return;
}
isWorking = true;
try {
await writePromise(json, xmlSettings, throttle);
} catch (err) {
console.error("Error writing xmltv", err);
}
isWorking = false;
}
function writePromise(json, xmlSettings, throttle) {
return new Promise((resolve, reject) => {
let ws = fs.createWriteStream(xmlSettings.file)
let xw = new XMLWriter(true, (str, enc) => ws.write(str, enc))
@ -59,7 +77,9 @@ function _writeChannels(xw, channels) {
async function _writePrograms(xw, channel, programs, throttle) {
for (let i = 0; i < programs.length; i++) {
await throttle();
if (! isShutdown) {
await throttle();
}
await _writeProgramme(channel, programs[i], xw);
}
}
@ -117,8 +137,25 @@ async function _writeProgramme(channel, program, xw) {
function _createXMLTVDate(d) {
return d.substring(0,19).replace(/[-T:]/g,"") + " +0000";
}
function _throttle() {
function wait(x) {
return new Promise((resolve) => {
setTimeout(resolve, 0);
setTimeout(resolve, x);
});
}
async function shutdown() {
isShutdown = true;
console.log("Shutting down xmltv writer.");
if (isWorking) {
let s = "Wait for xmltv writer...";
while (isWorking) {
console.log(s);
await wait(100);
s = "Still waiting for xmltv writer...";
}
console.log("Write finished.");
} else {
console.log("xmltv writer had no pending jobs.");
}
}

View File

@ -17,6 +17,24 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) {
scope.maxSize = 50000;
scope.programming = {
maxHeight: 30,
step : 1,
}
try {
let h = parseFloat( localStorage.getItem("channel-programming-list-height" ) );
if (isNaN(h)) {
h = 30;
}
h = Math.min(64, Math.max(1, h));
console.log("loaded=" + h);
scope.programming.maxHeight = h;
} catch (e) {
console.error(e);
}
scope.blockCount = 1;
scope.showShuffleOptions = (localStorage.getItem("channel-tools") === "on");
scope.reverseTools = (localStorage.getItem("channel-tools-position") === "left");
@ -220,18 +238,23 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) {
scope._selectedProgram = null
updateChannelDuration()
}
scope.dropFunction = (dropIndex, index, program) => {
if (scope.channel.programs[index].start == program.start) {
return false;
scope.dropFunction = (dropIndex, program) => {
let y = program.$index;
let z = dropIndex + scope.currentStartIndex - 1;
scope.channel.programs.splice(y, 1);
if (z >= y) {
z--;
}
setTimeout( () => {
scope.channel.programs.splice(dropIndex + index, 0, program);
updateChannelDuration()
scope.$apply();
}, 1);
return true;
scope.channel.programs.splice(z, 0, program );
updateChannelDuration();
$timeout();
return false;
}
scope.setUpWatcher = function setupWatchers() {
this.$watch('vsRepeat.startIndex', function(val) {
scope.currentStartIndex = val;
});
};
scope.finishedOfflineEdit = (program) => {
let editedProgram = scope.channel.programs[scope.selectedProgram];
@ -380,6 +403,7 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) {
newProgs.push(tmpProgs[keys[i]])
}
scope.channel.programs = newProgs
updateChannelDuration(); //oops someone forgot to add this
}
scope.removeOffline = () => {
let tmpProgs = []
@ -1400,6 +1424,8 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) {
scope.minBreakSize = -1;
scope.maxBreakSize = -1;
let breakSizeOptions = [
{ id: 10, description: "10 seconds" },
{ id: 15, description: "15 seconds" },
{ id: 30, description: "30 seconds" },
{ id: 45, description: "45 seconds" },
{ id: 60, description: "60 seconds" },
@ -1408,8 +1434,9 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) {
{ id: 180, description: "3 minutes" },
{ id: 300, description: "5 minutes" },
{ id: 450, description: "7.5 minutes" },
{ id: 600, description: "10 minutes" },
{ id: 1200, description: "20 minutes" },
{ id: 10*60, description: "10 minutes" },
{ id: 20*60, description: "20 minutes" },
{ id: 30*60, description: "30 minutes" },
]
scope.minBreakSizeOptions = [
{ id: -1, description: "Min Duration" },
@ -1535,6 +1562,28 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions) {
return options;
}
scope.programmingHeight = () => {
return scope.programming.maxHeight + "rem";
}
let setProgrammingHeight = (h) => {
scope.programming.step++;
$timeout( () => {
scope.programming.step--;
}, 1000 )
scope.programming.maxHeight = h;
localStorage.setItem("channel-programming-list-height", "" + h );
};
scope.programmingZoomIn = () => {
let h = scope.programming.maxHeight;
h = Math.min( Math.ceil(h + scope.programming.step ), 64);
setProgrammingHeight(h);
}
scope.programmingZoomOut = () => {
let h = scope.programming.maxHeight;
h = Math.max( Math.floor(h - scope.programming.step ), 2 );
setProgrammingHeight(h);
}
scope.refreshFillerStuff = () => {
if (typeof(scope.channel.fillerCollections) === 'undefined') {
return;

View File

@ -14,6 +14,40 @@ module.exports = function ($timeout) {
scope.visible = false;
scope.error = undefined;
function refreshContentIndexes() {
for (let i = 0; i < scope.content.length; i++) {
scope.content[i].$index = i;
}
}
scope.contentSplice = (a,b) => {
scope.content.splice(a,b)
refreshContentIndexes();
}
scope.dropFunction = (dropIndex, program) => {
let y = program.$index;
let z = dropIndex + scope.currentStartIndex - 1;
scope.content.splice(y, 1);
if (z >= y) {
z--;
}
scope.content.splice(z, 0, program );
$timeout();
return false;
}
scope.setUpWatcher = function setupWatchers() {
this.$watch('vsRepeat.startIndex', function(val) {
scope.currentStartIndex = val;
});
};
scope.movedFunction = (index) => {
console.log("movedFunction(" + index + ")");
}
scope.linker( (filler) => {
if ( typeof(filler) === 'undefined') {
scope.name = "";
@ -26,6 +60,7 @@ module.exports = function ($timeout) {
scope.id = filler.id;
scope.title = "Edit Filler List";
}
refreshContentIndexes();
scope.visible = true;
} );
@ -49,7 +84,10 @@ module.exports = function ($timeout) {
scope.visible = false;
scope.onDone( {
name: scope.name,
content: scope.content,
content: scope.content.map( (c) => {
delete c.$index
return c;
} ),
id: scope.id,
} );
}
@ -58,9 +96,11 @@ module.exports = function ($timeout) {
}
scope.sortFillers = () => {
scope.content.sort( (a,b) => { return a.duration - b.duration } );
refreshContentIndexes();
}
scope.fillerRemoveAllFiller = () => {
scope.content = [];
refreshContentIndexes();
}
scope.fillerRemoveDuplicates = () => {
function getKey(p) {
@ -77,12 +117,14 @@ module.exports = function ($timeout) {
}
}
scope.content = newFiller;
refreshContentIndexes();
}
scope.importPrograms = (selectedPrograms) => {
for (let i = 0, l = selectedPrograms.length; i < l; i++) {
selectedPrograms[i].commercials = []
}
scope.content = scope.content.concat(selectedPrograms);
refreshContentIndexes();
scope.showPlexLibrary = false;
}

View File

@ -26,6 +26,7 @@ module.exports = function ($timeout, dizquetv) {
scope.schedule = {
lateness : 0,
maxDays: 365,
flexPreference : "distribute",
slots : [],
pad: 1,
fake: { time: -1 },
@ -52,6 +53,9 @@ module.exports = function ($timeout, dizquetv) {
slots[i].order = "shuffle";
}
}
if (typeof(scope.schedule.flexPreference) === 'undefined') {
scope.schedule.flexPreference = "distribute";
}
scope.schedule.fake = {
time: -1,
}
@ -71,13 +75,23 @@ module.exports = function ($timeout, dizquetv) {
{ id: 10*60*1000 , description: "10 minutes" },
{ id: 15*60*1000 , description: "15 minutes" },
{ id: 1*60*60*1000 , description: "1 hour" },
{ id: 2*60*60*1000 , description: "2 hours" },
{ id: 3*60*60*1000 , description: "3 hours" },
{ id: 4*60*60*1000 , description: "4 hours" },
{ id: 8*60*60*1000 , description: "8 hours" },
{ id: 24*60*60*1000 , description: "I don't care about lateness" },
];
scope.flexOptions = [
{ id: "distribute", description: "Between videos" },
{ id: "end", description: "End of the slot" },
]
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: 10*60*1000, description: "0:00, 0:10, 0:20, ..., 0:50" },
{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" },
@ -137,6 +151,7 @@ module.exports = function ($timeout, dizquetv) {
} );
scope.finished = async (cancel) => {
scope.error = null;
if (!cancel) {
try {
scope.loading = true;

View File

@ -25,7 +25,7 @@
</a>
</small>
<small class="pull-right" style="padding: 5px;">
<a href="https://discord.gg/U64P9MR" title='Discord' >
<a href="https://discord.gg/FUpCyZBTDM" title='Discord' >
<span class="fab fa-discord"></span>
</a>
</small>

View File

@ -288,7 +288,6 @@ 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;

View File

@ -87,6 +87,25 @@
</label>
</div>
<div ng-style="{order: (reverseTools?1:4) }" >
<button class="btn btn-sm btn-outline-info btn-programming-tools"
ng-click="programmingZoomIn()"
title="Higher"
>
<span class="fa fa-arrow-circle-down"></span>
</button>
</div>
<div ng-style="{order: (reverseTools?1:4) }" >
<button class="btn btn-sm btn-outline-info btn-programming-tools"
ng-click="programmingZoomOut()"
title="Shorter"
>
<span class="fa fa-arrow-circle-up"></span>
</button>
</div>
<div ng-show='showShuffleOptions' ng-style="{order: (reverseTools?1:4) }" >
<button class="btn btn-sm btn-outline-info btn-programming-tools"
ng-click="toggleToolsDirection()"
@ -117,11 +136,20 @@
</div>
</div>
<div class="modal-body programming-panes" ng-show="tab == 'programming'">
<div class="modal-body programming-panes" ng-show="tab == 'programming'"
ng-style="{'max-height':programmingHeight()}"
>
<div class='row' ng-class="{'reverse': reverseTools }" >
<div vs-repeat="options" ng-class="{'programming-pane': true, 'col':true, 'd-block': showShuffleOptions, 'd-sm-none': showShuffleOptions, 'd-md-block' : showShuffleOptions, container: true, 'list-group': true, 'list-group-root': true, 'programming-programs': true}" ng-show="hasPrograms()">
<div ng-repeat="x in channel.programs track by x.$index" ng-click="selectProgram(x.$index)" dnd-list="" dnd-drop="dropFunction(index , x.$index, item)"
class="list-group-item flex-container program-row" dnd-draggable="x" dnd-moved="channel.programs.splice(x.$index, 1);" dnd-effect-allowed="move"
<div vs-repeat="options" ng-class="{'programming-pane': true, 'col':true, 'd-block': showShuffleOptions, 'd-sm-none': showShuffleOptions, 'd-md-block' : showShuffleOptions, container: true, 'list-group': true, 'list-group-root': true, 'programming-programs': true}" ng-show="hasPrograms()"
dnd-drop="dropFunction(index , item)"
dnd-list=""
ng-init="setUpWatcher()"
ng-if="true"
ng-style="{'max-height':programmingHeight()}"
>
<div ng-repeat="x in channel.programs track by x.$index"
ng-click="selectProgram(x.$index)"
class="list-group-item flex-container program-row" dnd-draggable="x" dnd-moved="" dnd-effect-allowed="move"
>
<div class="program-start">
{{ dateForGuide(x.start) }}
@ -145,7 +173,9 @@
<small class='text-info'>There are no programs in the channel, use the <i class='fas fa-plus'></i> button to add programs from your media library or use the Tools to add Flex time or a Channel Redirect</small>
</div>
<div class='col-md-4 col-sm-12 col-xl-6 col-lg-5 programming-pane tools-pane' ng-show="showShuffleOptions">
<div class='col-md-4 col-sm-12 col-xl-6 col-lg-5 programming-pane tools-pane' ng-show="showShuffleOptions"
ng-style="{'max-height':programmingHeight()}"
>
<div class="row">
<div class="col-xl-6 col-md-12" style="padding: 5px;" ng-show="hasPrograms()">

View File

@ -23,7 +23,7 @@
<div>
<button class="btn btn-sm btn-secondary btn-programming-tools"
ng-click="showTools = !showTools"
ng-show="channel.programs.length !== 0">
ng-show="content.length !== 0">
<span
class="fa {{ showTools ? 'fa-chevron-down' : 'fa-chevron-right'}}"></span>&nbsp;&nbsp;Tools
</button>
@ -61,9 +61,19 @@
</div>
</div>
<div vs-repeat class="modal-body container list-group list-group-root filler-list"
dnd-list="content" ng-if="showList()"
vs-repeat-reinitialized="vsReinitialized(event, startIndex, endIndex)"
ng-init="setUpWatcher()"
dnd-drop="dropFunction(index , item)"
dnd-list=""
<div class="modal-body container list-group list-group-root filler-list" vs-repeat="options" dnd-list="content" ng-if="showList()">
<div class="list-group-item flex-container filler-row" style="cursor: default;" ng-repeat="x in content" dnd-draggable="x" dnd-moved="content.splice($index, 1)" dnd-effect-allowed="move">
>
<div class="list-group-item flex-container filler-row" style="cursor: default; height:1.1em; overflow:hidden" ng-repeat="x in content" track-by="x.$index" dnd-draggable="x"
"
dnd-effect-allowed="move"
dnd-moved="movedFunction(x.$index)"
>
<div class="program-start" >
{{durationString(x.duration)}}
</div>
@ -72,7 +82,7 @@
{{x.title}}
</div>
<div class="flex-pull-right">
<button class="btn btn-sm btn-link" ng-click="content.splice($index,1)">
<button class="btn btn-sm btn-link" ng-click="contentSplice(x.$index,1)">
<i class="text-danger fa fa-trash-alt" ></i>
</button>
</div>

View File

@ -89,12 +89,25 @@
aria-describedby="padHelp"
>
</select>
<small id='latenessHelp' class='form-text text-muted'>
<small id='padHelp' class='form-text text-muted'>
Ensures programs have a nice-looking start time, it will add Flex time to fill the gaps.
</small>
</div>
<div class='form-group'>
<label for="pad">What to do with flex?</label>
<select
id="flexPreference" class="custom-select form-control"
ng-model="schedule.flexPreference" ng-options="o.id as o.description for o in flexOptions"
aria-describedby="flexPreferenceHelp"
>
</select>
<small id='flexPreferenceHelp' class='form-text text-muted'>
Usually slots need to add flex time to ensure that the next slot starts at the correct time. When there are multiple videos in the slot, you might prefer to distribute the flex time between the videos or to place most of the flex time at the end of the slot.
</small>
</div>
<div class='form-group'>
<label for="lateness">Maximum days to precalculate</label>
<input