From 244b956936b4e6b6eb44a5a7d7f875e5df07ea9c Mon Sep 17 00:00:00 2001 From: Cesar Mendivil Date: Sun, 15 Feb 2026 18:48:50 -0700 Subject: [PATCH] Implement split-screen video upload and configuration options --- index.js | 1 + src/api.js | 107 ++++++++++++++++++ src/database-migration.js | 8 ++ src/ffmpeg.js | 130 +++++++++++++++++++--- src/helperFuncs.js | 34 ++++++ src/plex-player.js | 3 +- src/program-player.js | 3 + src/video.js | 8 +- web/directives/channel-config.js | 40 ++++++- web/directives/ffmpeg-settings.js | 16 +++ web/public/templates/channel-config.html | 51 ++++++++- web/public/templates/ffmpeg-settings.html | 37 ++++++ web/services/dizquetv.js | 8 ++ 13 files changed, 428 insertions(+), 18 deletions(-) diff --git a/index.js b/index.js index 5805ef7..1cf50e0 100644 --- a/index.js +++ b/index.js @@ -282,6 +282,7 @@ app.get('/version.js', (req, res) => { app.use('/images', express.static(path.join(process.env.DATABASE, 'images'))) app.use(express.static(path.join(__dirname, 'web','public'))) app.use('/images', express.static(path.join(process.env.DATABASE, 'images'))) +app.use('/videos', express.static(path.join(process.env.DATABASE, 'videos'))) app.use('/cache/images', cacheImageService.routerInterceptor()) app.use('/cache/images', express.static(path.join(process.env.DATABASE, 'cache','images'))) app.use('/favicon.svg', express.static( diff --git a/src/api.js b/src/api.js index 7d21f4d..ed810ab 100644 --- a/src/api.js +++ b/src/api.js @@ -383,6 +383,113 @@ function api(db, channelService, fillerDB, customShowDB, xmltvInterval, guideSe } }) + router.post('/api/upload/video', async (req, res) => { + try { + if(!req.files) { + res.send({ + status: false, + message: 'No file uploaded' + }); + return; + } + + const vid = req.files.video; + // Basic validation: extension + const allowed = ['.mp4', '.m4v']; + const ext = path.extname(vid.name).toLowerCase(); + if (!allowed.includes(ext)) { + return res.status(400).send({ status: false, message: 'Unsupported file type' }); + } + + // If upload is associated to a channel, enforce 15 MB limit and store under channel hierarchy + let channelNumber = null; + try { + if (req.body && typeof(req.body.channel) !== 'undefined' && req.body.channel !== null && req.body.channel !== '') { + channelNumber = parseInt(req.body.channel, 10); + if (isNaN(channelNumber)) channelNumber = null; + } + } catch (e) { + channelNumber = null; + } + + const MAX_BYTES_CHANNEL = 15 * 1024 * 1024; + const MAX_BYTES_GENERIC = 25 * 1024 * 1024; + const maxAllowed = (channelNumber !== null) ? MAX_BYTES_CHANNEL : MAX_BYTES_GENERIC; + if (vid.size > maxAllowed) { + return res.status(400).send({ status: false, message: 'File too large' }); + } + + if (channelNumber !== null) { + const uploadDir = path.join(process.env.DATABASE, '/videos/', '' + channelNumber, '/split-screen/'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + // avoid collisions + const safeName = `${Date.now()}-${vid.name}`; + const dest = path.join(uploadDir, safeName); + await vid.mv(dest); + + const fileUrl = `${req.protocol}://${req.get('host')}/videos/${channelNumber}/split-screen/${safeName}`; + + // Persist into channel record if possible + try { + let channel = await channelService.getChannel(channelNumber); + if (channel != null) { + if (typeof(channel.splitScreen) === 'undefined' || channel.splitScreen == null) { + channel.splitScreen = { + useGlobal: false, + enabled: true, + source: fileUrl, + widthPercent: 35, + loop: true, + } + } else { + channel.splitScreen.source = fileUrl; + channel.splitScreen.useGlobal = false; + channel.splitScreen.enabled = true; + } + await channelService.saveChannel(channelNumber, channel); + } + } catch (e) { + console.error('Error persisting channel splitScreen info', e); + } + + return res.send({ + status: true, + message: 'File is uploaded', + data: { + name: safeName, + mimetype: vid.mimetype, + size: vid.size, + fileUrl: fileUrl + } + }); + } else { + // Generic uploads (no channel) -> previous behavior + const uploadDir = path.join(process.env.DATABASE, '/videos/uploads/'); + if (!fs.existsSync(uploadDir)) { + fs.mkdirSync(uploadDir, { recursive: true }); + } + const dest = path.join(uploadDir, vid.name); + await vid.mv(dest); + + return res.send({ + status: true, + message: 'File is uploaded', + data: { + name: vid.name, + mimetype: vid.mimetype, + size: vid.size, + fileUrl: `${req.protocol}://${req.get('host')}/videos/uploads/${vid.name}` + } + }); + } + } catch (err) { + console.error('Error in /api/upload/video', err); + res.status(500).send(err); + } + }) + // Filler router.get('/api/fillers', async (req, res) => { try { diff --git a/src/database-migration.js b/src/database-migration.js index cef34a7..38f010d 100644 --- a/src/database-migration.js +++ b/src/database-migration.js @@ -411,6 +411,14 @@ function ffmpeg() { maxFPS: 60, scalingAlgorithm: "bicubic", deinterlaceFilter: "none", + // Split-screen/secondary looped video (optional) + splitScreenEnabled: false, + // URL or local path to the secondary video to loop (can be http(s) or file path) + splitScreenSource: "", + // Percentage width of the secondary (right) video relative to output width + splitScreenWidthPercent: 35, + // If true, ffmpeg will attempt to loop the secondary input (via -stream_loop) + splitScreenLoop: true, } } diff --git a/src/ffmpeg.js b/src/ffmpeg.js index c503279..b728363 100644 --- a/src/ffmpeg.js +++ b/src/ffmpeg.js @@ -2,12 +2,12 @@ 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) { super() - this.opts = opts; + // Clone opts so per-channel overrides don't mutate the global settings + this.opts = JSON.parse(JSON.stringify(opts || {})); this.errorPicturePath = `http://localhost:${process.env.PORT}/images/generic-error-screen.png`; this.ffmpegName = "unnamed ffmpeg"; if (! this.opts.enableFFMPEGTranscoding) { @@ -22,9 +22,9 @@ class FFMPEG extends events.EventEmitter { this.opts.maxFPS = REALLY_RIDICULOUSLY_HIGH_FPS_FOR_DIZQUETVS_USECASE; } this.channel = channel - this.ffmpegPath = opts.ffmpegPath + this.ffmpegPath = this.opts.ffmpegPath - let resString = opts.targetResolution; + let resString = this.opts.targetResolution; if ( (typeof(channel.transcoding) !== 'undefined') && (channel.transcoding.targetResolution != null) @@ -40,7 +40,7 @@ class FFMPEG extends events.EventEmitter { && (typeof(channel.transcoding.videoBitrate) != 'undefined') && (channel.transcoding.videoBitrate != 0) ) { - opts.videoBitrate = channel.transcoding.videoBitrate; + this.opts.videoBitrate = channel.transcoding.videoBitrate; } if ( @@ -49,13 +49,36 @@ class FFMPEG extends events.EventEmitter { && (typeof(channel.transcoding.videoBufSize) != 'undefined') && (channel.transcoding.videoBufSize != 0) ) { - opts.videoBufSize = channel.transcoding.videoBufSize; + this.opts.videoBufSize = channel.transcoding.videoBufSize; } let parsed = parseResolutionString(resString); this.wantedW = parsed.w; this.wantedH = parsed.h; + // Apply per-channel transcoding overrides + if (channel && typeof(channel.transcoding) !== 'undefined') { + if (typeof channel.transcoding.targetResolution === 'string' && channel.transcoding.targetResolution !== '') { + let parsed = resolutionMap.parseResolutionString(channel.transcoding.targetResolution); + this.wantedW = parsed.w; + this.wantedH = parsed.h; + } + if (typeof channel.transcoding.videoBitrate === 'number' && channel.transcoding.videoBitrate > 0) { + this.opts.videoBitrate = channel.transcoding.videoBitrate; + } + if (typeof channel.transcoding.videoBufSize === 'number' && channel.transcoding.videoBufSize > 0) { + this.opts.videoBufSize = channel.transcoding.videoBufSize; + } + // Get videoFlip from channel transcoding settings + if (typeof channel.transcoding.videoFlip === 'string' && channel.transcoding.videoFlip !== '') { + this.opts.videoFlip = channel.transcoding.videoFlip; + } + } + // Ensure videoFlip default + if (typeof this.opts.videoFlip !== 'string') { + this.opts.videoFlip = 'none'; + } + this.sentData = false; this.apad = this.opts.normalizeAudio; this.audioChannelsSampleRate = this.opts.normalizeAudio; @@ -67,11 +90,11 @@ class FFMPEG extends events.EventEmitter { setAudioOnly(audioOnly) { this.audioOnly = audioOnly; } - async spawnConcat(streamUrl) { - return await this.spawn(streamUrl, undefined, undefined, undefined, true, false, undefined, true) + async spawnConcat(streamUrl, splitScreen) { + return await this.spawn(streamUrl, undefined, undefined, undefined, true, false, undefined, true, splitScreen) } - async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type) { - return await this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false); + async spawnStream(streamUrl, streamStats, startTime, duration, enableIcon, type, splitScreen) { + return await this.spawn(streamUrl, streamStats, startTime, duration, true, enableIcon, type, false, splitScreen); } async spawnError(title, subtitle, duration) { if (! this.opts.enableFFMPEGTranscoding || this.opts.errorScreen == 'kill') { @@ -90,7 +113,7 @@ class FFMPEG extends events.EventEmitter { videoHeight : this.wantedH, duration : duration, }; - return await this.spawn({ errorTitle: title , subtitle: subtitle }, streamStats, undefined, `${streamStats.duration}ms`, true, false, 'error', false) + return await this.spawn({ errorTitle: title , subtitle: subtitle }, streamStats, undefined, `${streamStats.duration}ms`, true, false, 'error', false, null) } async spawnOffline(duration) { if (! this.opts.enableFFMPEGTranscoding) { @@ -104,10 +127,11 @@ class FFMPEG extends events.EventEmitter { videoHeight : this.wantedH, duration : duration, }; - return await this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false); + return await this.spawn( {errorTitle: 'offline'}, streamStats, undefined, `${duration}ms`, true, false, 'offline', false, null); } - async spawn(streamUrl, streamStats, startTime, duration, limitRead, watermark, type, isConcatPlaylist) { + async spawn(streamUrl, streamStats, startTime, duration, limitRead, watermark, type, isConcatPlaylist, splitScreen) { + console.log("[DEBUG] FFMPEG.spawn received splitScreen:", JSON.stringify(splitScreen)); let ffmpegArgs = [ `-threads`, isConcatPlaylist? 1 : this.opts.threads, `-fflags`, `+genpts+discardcorrupt+igndts`]; @@ -319,12 +343,48 @@ class FFMPEG extends events.EventEmitter { currentVideo = "[videox]"; } if (doOverlay) { + // Support for channel watermark (image) and optional split-screen + // secondary video. If the watermark is provided as before, use it + // as an image overlay. If ffmpeg settings enable a split-screen + // secondary, add it as another input and later compose using + // filter_complex. if (watermark.animated === true) { ffmpegArgs.push('-ignore_loop', '0'); } ffmpegArgs.push(`-i`, `${watermark.url}` ); overlayFile = inputFiles++; this.ensureResolution = true; + + // If split-screen secondary is provided via parameter, attach it + if (splitScreen && splitScreen.enabled && splitScreen.source && splitScreen.source.trim() !== '' && this.audioOnly !== true) { + // If requested, try to loop the source using -stream_loop + if (splitScreen.loop === true) { + // -stream_loop value must come before the -i for the input + ffmpegArgs.push('-stream_loop', '-1'); + } + ffmpegArgs.push('-i', `${splitScreen.source}`); + var splitOverlayFile = inputFiles++; + // mark that we will need to compose the secondary video later + this._splitOverlayFile = splitOverlayFile; + this._splitScreenConfig = splitScreen; + this.ensureResolution = true; + } + } + + // If watermark handling was skipped (watermark === null) we still + // want to allow adding the split-screen secondary input. Add the + // secondary input here if it wasn't already added above. + if (typeof this._splitOverlayFile === 'undefined') { + if (splitScreen && splitScreen.enabled && splitScreen.source && splitScreen.source.trim() !== '' && this.audioOnly !== true) { + if (splitScreen.loop === true) { + ffmpegArgs.push('-stream_loop', '-1'); + } + ffmpegArgs.push('-i', `${splitScreen.source}`); + var splitOverlayFile = inputFiles++; + this._splitOverlayFile = splitOverlayFile; + this._splitScreenConfig = splitScreen; + this.ensureResolution = true; + } } // Resolution fix: Add scale filter, current stream becomes [siz] @@ -389,6 +449,15 @@ class FFMPEG extends events.EventEmitter { iH = this.wantedH; } + // Apply videoFlip to main video before watermark/split-screen composition + if (this.opts.videoFlip === 'hflip') { + videoComplex += `;${currentVideo}hflip[flipped]`; + currentVideo = '[flipped]'; + } else if (this.opts.videoFlip === 'vflip') { + videoComplex += `;${currentVideo}vflip[flipped]`; + currentVideo = '[flipped]'; + } + // Channel watermark: if (doOverlay && (this.audioOnly !== true) ) { var pW =watermark.width; @@ -425,6 +494,32 @@ class FFMPEG extends events.EventEmitter { currentVideo = '[comb]'; } + // Split-screen composition (independent of watermark) + // If we have a split-screen secondary input configured, compose + // it to the right of the main video using hstack after scaling + if (typeof(this._splitOverlayFile) !== 'undefined' && this._splitScreenConfig && this.audioOnly !== true) { + console.log("[DEBUG] Building split-screen composition..."); + try { + const splitPercent = Number(this._splitScreenConfig.widthPercent) || 35; + const sideW = Math.max(16, Math.round(this.wantedW * splitPercent / 100.0)); + const mainW = Math.max(16, this.wantedW - sideW); + console.log(`[DEBUG] Split-screen dimensions: main=${mainW}x${this.wantedH}, side=${sideW}x${this.wantedH}`); + // scale secondary to wanted height, preserving aspect + videoComplex += `;[${this._splitOverlayFile}:v]scale=${sideW}:${this.wantedH}:force_original_aspect_ratio=decrease[side_scaled]`; + // scale main (currentVideo) to remaining width and wanted height + videoComplex += `;${currentVideo}scale=${mainW}:${this.wantedH}:force_original_aspect_ratio=decrease[main_scaled]`; + // pad both to exact dimensions if necessary + videoComplex += `;[main_scaled]pad=${mainW}:${this.wantedH}:(ow-iw)/2:(oh-ih)/2[main_padded]`; + videoComplex += `;[side_scaled]pad=${sideW}:${this.wantedH}:(ow-iw)/2:(oh-ih)/2[side_padded]`; + // stack horizontally + videoComplex += `;[main_padded][side_padded]hstack=inputs=2[comb2]`; + currentVideo = '[comb2]'; + console.log("[DEBUG] Split-screen composition added to filter_complex"); + } catch (e) { + console.error("Error while building split-screen filters:", e); + } + } + if (this.volumePercent != 100) { var f = this.volumePercent / 100.0; @@ -569,7 +664,14 @@ class FFMPEG extends events.EventEmitter { if (this.hasBeenKilled) { return ; } - //console.log(this.ffmpegPath + " " + ffmpegArgs.join(" ") ); + // Log the full ffmpeg command when requested so it's easy to debug + if (this.opts.logFfmpeg) { + try { + console.log("FFMPEG CMD:", this.ffmpegPath, ffmpegArgs.join(' ')); + } catch (e) { + console.log("FFMPEG CMD (could not join args)"); + } + } this.ffmpeg = spawn(this.ffmpegPath, ffmpegArgs, { stdio: ['ignore', 'pipe', (doLogs?process.stderr:"ignore") ] } ); if (this.hasBeenKilled) { console.log("Send SIGKILL to ffmpeg"); diff --git a/src/helperFuncs.js b/src/helperFuncs.js index 1d905f9..3c6ab56 100644 --- a/src/helperFuncs.js +++ b/src/helperFuncs.js @@ -2,6 +2,7 @@ module.exports = { getCurrentProgramAndTimeElapsed: getCurrentProgramAndTimeElapsed, createLineup: createLineup, getWatermark: getWatermark, + getSplitScreen: getSplitScreen, generateChannelContext: generateChannelContext, } @@ -346,6 +347,39 @@ function getWatermark( ffmpegSettings, channel, type) { } +// Return split-screen configuration. This is intentionally independent +// from watermark logic and reads global settings with optional per-channel +// overrides when `channel.splitScreen.useGlobal` is false. +function getSplitScreen(ffmpegSettings, channel) { + let result = null; + + // Check if channel has overrides (not using global) + if (channel && typeof channel.splitScreen !== 'undefined' && channel.splitScreen !== null && channel.splitScreen.useGlobal !== true) { + // Use channel-specific settings + result = { + enabled: !!channel.splitScreen.enabled, + source: (typeof channel.splitScreen.source === 'string') ? channel.splitScreen.source : '', + widthPercent: Number(channel.splitScreen.widthPercent) || 35, + loop: !!channel.splitScreen.loop, + }; + } else { + // Use global settings + if (!ffmpegSettings || !ffmpegSettings.splitScreenEnabled) { + return null; + } + result = { + enabled: !!ffmpegSettings.splitScreenEnabled, + source: (typeof ffmpegSettings.splitScreenSource === 'string') ? ffmpegSettings.splitScreenSource : '', + widthPercent: Number(ffmpegSettings.splitScreenWidthPercent) || 35, + loop: !!ffmpegSettings.splitScreenLoop, + }; + } + + if (!result.enabled || !result.source || result.source.trim() === '') return null; + return result; +} + + function getFillerMedian(programPlayTime, channel, filler) { let times = []; diff --git a/src/plex-player.js b/src/plex-player.js index 9a75f84..d9ecb15 100644 --- a/src/plex-player.js +++ b/src/plex-player.js @@ -61,6 +61,7 @@ class PlexPlayer { let plexTranscoder = new PlexTranscoder(this.clientId, server, plexSettings, channel, lineupItem); this.plexTranscoder = plexTranscoder; let watermark = this.context.watermark; + let splitScreen = this.context.splitScreen; let ffmpeg = new FFMPEG(ffmpegSettings, channel); // Set the transcoder options ffmpeg.setAudioOnly( this.context.audioOnly ); this.ffmpeg = ffmpeg; @@ -85,7 +86,7 @@ class PlexPlayer { let emitter = new EventEmitter(); //setTimeout( () => { - let ff = await ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, watermark, lineupItem.type); // Spawn the ffmpeg process + let ff = await ffmpeg.spawnStream(stream.streamUrl, stream.streamStats, streamStart, streamDuration, watermark, lineupItem.type, splitScreen); // 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 d7ceea4..dc380aa 100644 --- a/src/program-player.js +++ b/src/program-player.js @@ -58,6 +58,9 @@ class ProgramPlayer { this.delegate = new PlexPlayer(context); } this.context.watermark = helperFuncs.getWatermark( context.ffmpegSettings, context.channel, context.lineupItem.type); + // Compute split-screen separately from watermark so it's independent + // of overlay settings and filler overlay disabling. + this.context.splitScreen = helperFuncs.getSplitScreen( context.ffmpegSettings, context.channel ); } cleanUp() { diff --git a/src/video.js b/src/video.js index 7d2047f..a5a43e7 100644 --- a/src/video.js +++ b/src/video.js @@ -125,7 +125,13 @@ function video( channelService, fillerDB, db, programmingService, activeChannelS }) let channelNum = parseInt(req.query.channel, 10) - let ff = await ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}&audioOnly=${audioOnly}&stepNumber={step}`); + // For concat mode, attempt to read splitScreen from channel + let splitScreen = null; + try { + const helperFuncs = require('./helperFuncs'); + splitScreen = helperFuncs.getSplitScreen(ffmpegSettings, channel); + } catch (e) {} + let ff = await ffmpeg.spawnConcat(`http://localhost:${process.env.PORT}/playlist?channel=${channelNum}&audioOnly=${audioOnly}&stepNumber={step}`, splitScreen); ff.pipe(res, { end: false} ); }; router.get('/video', async(req, res) => { diff --git a/web/directives/channel-config.js b/web/directives/channel-config.js index d8da0fe..184e492 100644 --- a/web/directives/channel-config.js +++ b/web/directives/channel-config.js @@ -86,6 +86,16 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get scope.showRotatedNote = false; scope.channel.transcoding = { targetResolution: "", + videoFlip: 'none', + } + scope.channel.splitScreen = { + // If true, use the global ffmpeg split-screen settings instead + // of the channel specific ones + useGlobal: true, + enabled: false, + source: "", + widthPercent: 35, + loop: true, } scope.channel.onDemand = { isOnDemand : false, @@ -137,7 +147,21 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get } if (typeof(scope.channel.transcoding) ==='undefined') { - scope.channel.transcoding = {}; + scope.channel.transcoding = { + videoFlip: 'none', + }; + } + if (typeof(scope.channel.transcoding.videoFlip) === 'undefined') { + scope.channel.transcoding.videoFlip = 'none'; + } + if (typeof(scope.channel.splitScreen) === 'undefined') { + scope.channel.splitScreen = { + useGlobal: true, + enabled: false, + source: "", + widthPercent: 35, + loop: true, + } } if ( (scope.channel.transcoding.targetResolution == null) @@ -1796,6 +1820,20 @@ module.exports = function ($timeout, $location, dizquetv, resolutionOptions, get }) } + scope.splitScreenOnChange = (event) => { + const formData = new FormData(); + formData.append('video', event.target.files[0]); + // include channel number so server stores file under channel hierarchy and persists it + if (typeof(scope.channel) !== 'undefined' && scope.channel != null && typeof(scope.channel.number) !== 'undefined') { + formData.append('channel', scope.channel.number); + } + dizquetv.uploadVideo(formData).then((response) => { + scope.channel.splitScreen.source = response.data.fileUrl; + }).catch((err) => { + console.error('Error uploading split-screen video', err); + }) + } + }, diff --git a/web/directives/ffmpeg-settings.js b/web/directives/ffmpeg-settings.js index e474097..fd236f7 100644 --- a/web/directives/ffmpeg-settings.js +++ b/web/directives/ffmpeg-settings.js @@ -9,11 +9,14 @@ module.exports = function (dizquetv, resolutionOptions) { //add validations to ffmpeg settings, speciall commas in codec name dizquetv.getFfmpegSettings().then((settings) => { scope.settings = settings + // ensure videoFlip default exists + scope.settings.videoFlip = scope.settings.videoFlip || 'none'; }) scope.updateSettings = (settings) => { delete scope.settingsError; dizquetv.updateFfmpegSettings(settings).then((_settings) => { scope.settings = _settings + scope.settings.videoFlip = scope.settings.videoFlip || 'none'; }).catch( (err) => { if ( typeof(err.data) === "string") { scope.settingsError = err.data; @@ -23,6 +26,7 @@ module.exports = function (dizquetv, resolutionOptions) { scope.resetSettings = (settings) => { dizquetv.resetFfmpegSettings(settings).then((_settings) => { scope.settings = _settings + scope.settings.videoFlip = scope.settings.videoFlip || 'none'; }) } scope.isTranscodingNotNeeded = () => { @@ -80,6 +84,18 @@ module.exports = function (dizquetv, resolutionOptions) { {value: "yadif=1", description: "yadif send field"} ]; + scope.uploadGlobalSplitVideoOnChange = (event) => { + const formData = new FormData(); + formData.append('video', event.target.files[0]); + // No channel parameter -> generic upload + dizquetv.uploadVideo(formData).then((response) => { + scope.settings.splitScreenSource = response.data.fileUrl; + scope.$applyAsync(); + }).catch((err) => { + console.error('Error uploading global split-screen video', err); + }) + } + } } } \ No newline at end of file diff --git a/web/public/templates/channel-config.html b/web/public/templates/channel-config.html index 081300d..cd6e3a0 100644 --- a/web/public/templates/channel-config.html +++ b/web/public/templates/channel-config.html @@ -758,6 +758,45 @@ Renders a channel icon (also known as bug or Digital On-screen Graphic) on top of the channel's stream. +
+
+ +
+
+ + +
+ Split-Screen (per-channel) +
+ + + If unchecked, the channel-specific split-screen settings below will be used instead of the global ones. +
+ +
+
+ + +
+
+ +
+ +
+ +
+
+

+ + +

+ + +
+
+ +
+
@@ -840,7 +879,7 @@ Tick this if and only if the watermark is an animated GIF or PNG. It will make it loop or not loop according to the image's configuration. If the image is not animated, there will be playback errors.
-
+
+ +
+ + + Leave unassigned to use the global setting +
diff --git a/web/public/templates/ffmpeg-settings.html b/web/public/templates/ffmpeg-settings.html index bcaa3f7..148cdae 100644 --- a/web/public/templates/ffmpeg-settings.html +++ b/web/public/templates/ffmpeg-settings.html @@ -151,6 +151,15 @@ Deinterlace filter to use when video is interlaced. This is only needed when Plex transcoding is not used. + +

+ + + Apply horizontal or vertical flip to the video stream.
@@ -239,6 +248,34 @@
+

+
+
+
+ + + When enabled, a secondary video (local path or remote URL) will be played repeatedly and composited on the side of the main video. +
+
+
+ +
+ +
+ +
+ +
+
+

+ + +

+ + + If checked, dizqueTV will request ffmpeg to loop the secondary source when possible. +
+

diff --git a/web/services/dizquetv.js b/web/services/dizquetv.js index 4e8b8f6..1141fe5 100644 --- a/web/services/dizquetv.js +++ b/web/services/dizquetv.js @@ -173,6 +173,14 @@ module.exports = function ($http, $q) { headers: { 'Content-Type': undefined } }).then((d) => { return d.data }) }, + uploadVideo: (file) => { + return $http({ + method: 'POST', + url: '/api/upload/video', + data: file, + headers: { 'Content-Type': undefined } + }).then((d) => { return d.data }) + }, updateChannel: (channel) => { return $http({ method: 'PUT',