diff --git a/backend/app.js b/backend/app.js index 690edbc..abe36ac 100644 --- a/backend/app.js +++ b/backend/app.js @@ -1,4 +1,5 @@ var async = require('async'); +const { uuid } = require('uuidv4'); var fs = require('fs'); var path = require('path'); var youtubedl = require('youtube-dl'); @@ -10,7 +11,9 @@ var archiver = require('archiver'); const low = require('lowdb') var URL = require('url').URL; const shortid = require('shortid') +const url_api = require('url'); var config_api = require('./config.js'); +var subscriptions_api = require('./subscriptions') var app = express(); @@ -25,7 +28,8 @@ db.defaults( audio: [], video: [] }, - configWriteFlag: false + configWriteFlag: false, + subscriptions: [] }).write(); // config values @@ -39,6 +43,8 @@ var videoFolderPath = null; var downloadOnlyMode = null; var useDefaultDownloadingAgent = null; var customDownloadingAgent = null; +var allowSubscriptions = null; +var subscriptionsCheckInterval = null; // other needed values var options = null; // encryption options @@ -129,6 +135,9 @@ async function loadConfig() { downloadOnlyMode = config_api.getConfigItem('ytdl_download_only_mode'); useDefaultDownloadingAgent = config_api.getConfigItem('ytdl_use_default_downloading_agent'); customDownloadingAgent = config_api.getConfigItem('ytdl_custom_downloading_agent'); + allowSubscriptions = config_api.getConfigItem('ytdl_allow_subscriptions'); + subscriptionsCheckInterval = config_api.getConfigItem('ytdl_subscriptions_check_interval'); + if (!useDefaultDownloadingAgent && validDownloadingAgents.indexOf(customDownloadingAgent) !== -1 ) { console.log(`INFO: Using non-default downloading agent \'${customDownloadingAgent}\'`) } @@ -149,6 +158,11 @@ async function loadConfig() { url_domain = new URL(url); + // get subscriptions + if (allowSubscriptions) { + watchSubscriptions(); + } + // start the server here startServer(); @@ -157,6 +171,34 @@ async function loadConfig() { } +function calculateSubcriptionRetrievalDelay(amount) { + // frequency is 5 mins + let frequency_in_ms = subscriptionsCheckInterval * 1000; + let minimum_frequency = 60 * 1000; + const first_frequency = frequency_in_ms/amount; + return (first_frequency < minimum_frequency) ? minimum_frequency : first_frequency; +} + +function watchSubscriptions() { + let subscriptions = subscriptions_api.getAllSubscriptions(); + + let subscriptions_amount = subscriptions.length; + let delay_interval = calculateSubcriptionRetrievalDelay(subscriptions_amount); + + let current_delay = 0; + for (let i = 0; i < subscriptions.length; i++) { + let sub = subscriptions[i]; + console.log('watching ' + sub.name + ' with delay interval of ' + delay_interval); + setTimeout(() => { + setInterval(() => { + subscriptions_api.getVideosForSub(sub); + }, subscriptionsCheckInterval * 1000); + }, current_delay); + current_delay += delay_interval; + if (current_delay >= subscriptionsCheckInterval * 1000) current_delay = 0; + } +} + function getOrigin() { return url_domain.origin; } @@ -239,9 +281,14 @@ function getJSONMp3(name) return obj; } -function getJSONMp4(name) +function getJSONMp4(name, customPath = null) { - var jsonPath = videoFolderPath+name+".info.json"; + let jsonPath = null; + if (!customPath) { + jsonPath = videoFolderPath+name+".info.json"; + } else { + jsonPath = customPath + name + ".info.json"; + } if (fs.existsSync(jsonPath)) { var obj = JSON.parse(fs.readFileSync(jsonPath, 'utf8')); @@ -802,6 +849,109 @@ app.post('/api/getMp4s', function(req, res) { res.end("yes"); }); +app.post('/api/subscribe', async (req, res) => { + let name = req.body.name; + let url = req.body.url; + let timerange = req.body.timerange; + + const new_sub = { + name: name, + url: url, + id: uuid() + }; + + // adds timerange if it exists, otherwise all videos will be downloaded + if (timerange) { + new_sub.timerange = timerange; + } + + const result_obj = await subscriptions_api.subscribe(new_sub); + + if (result_obj.success) { + res.send({ + new_sub: new_sub + }); + } else { + res.send({ + new_sub: null, + error: result_obj.error + }) + } +}); + +app.post('/api/unsubscribe', async (req, res) => { + let deleteMode = req.body.deleteMode + let sub = req.body.sub; + + let result_obj = subscriptions_api.unsubscribe(sub, deleteMode); + if (result_obj.success) { + res.send({ + success: result_obj.success + }); + } else { + res.send({ + success: false, + error: result_obj.error + }); + } +}); + +app.post('/api/getSubscription', async (req, res) => { + let subID = req.body.id; + + // get sub from db + let subscription = subscriptions_api.getSubscription(subID); + + if (!subscription) { + // failed to get subscription from db, send 400 error + res.sendStatus(400); + return; + } + + // get sub videos + let base_path = config_api.getConfigItem('ytdl_subscriptions_base_path'); + let appended_base_path = path.join(base_path, subscription.isPlaylist ? 'playlists' : 'channels', subscription.name, '/'); + var files = recFindByExt(appended_base_path, 'mp4'); + var parsed_files = []; + for (let i = 0; i < files.length; i++) { + let file = files[i]; + var file_path = file.substring(appended_base_path.length, file.length); + var id = file_path.substring(0, file_path.length-4); + var jsonobj = getJSONMp4(id, appended_base_path); + if (!jsonobj) continue; + var title = jsonobj.title; + + var thumbnail = jsonobj.thumbnail; + var duration = jsonobj.duration; + var isaudio = false; + var file_obj = new File(id, title, thumbnail, isaudio, duration); + parsed_files.push(file_obj); + } + + res.send({ + subscription: subscription, + files: parsed_files + }); +}); + +app.post('/api/downloadVideosForSubscription', async (req, res) => { + let subID = req.body.subID; + let sub = subscriptions_api.getSubscription(subID); + subscriptions_api.getVideosForSub(sub); + res.send({ + success: true + }); +}); + +app.post('/api/getAllSubscriptions', async (req, res) => { + // get subs from api + let subscriptions = subscriptions_api.getAllSubscriptions(); + + res.send({ + subscriptions: subscriptions + }); +}); + app.post('/api/createPlaylist', async (req, res) => { let playlistName = req.body.playlistName; let fileNames = req.body.fileNames; @@ -948,8 +1098,15 @@ app.post('/api/deleteFile', async (req, res) => { app.get('/api/video/:id', function(req , res){ var head; + let optionalParams = url_api.parse(req.url,true).query; let id = decodeURIComponent(req.params.id); - const path = "video/" + id + '.mp4'; + let path = "video/" + id + '.mp4'; + if (optionalParams['subName']) { + let basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + const isPlaylist = optionalParams['subPlaylist']; + basePath += (isPlaylist === 'true' ? 'playlists/' : 'channels/'); + path = basePath + optionalParams['subName'] + '/' + id + '.mp4'; + } const stat = fs.statSync(path) const fileSize = stat.size const range = req.headers.range diff --git a/backend/config.js b/backend/config.js index ee31843..4a5334b 100644 --- a/backend/config.js +++ b/backend/config.js @@ -56,7 +56,10 @@ function setConfigFile(config) { function getConfigItem(key) { let config_json = getConfigFile(); - if (!CONFIG_ITEMS[key]) console.log('cannot find config with key ' + key); + if (!CONFIG_ITEMS[key]) { + console.log('cannot find config with key ' + key); + return null; + } let path = CONFIG_ITEMS[key]['path']; return Object.byString(config_json, path); }; diff --git a/backend/config/default.json b/backend/config/default.json index 051c68c..df8fd5b 100644 --- a/backend/config/default.json +++ b/backend/config/default.json @@ -28,6 +28,12 @@ "default_theme": "default", "allow_theme_change": true }, + "Subscriptions": { + "allow_subscriptions": true, + "subscriptions_base_path": "subscriptions/", + "subscriptions_check_interval": "300", + "subscriptions_use_youtubedl_archive": true + }, "Advanced": { "use_default_downloading_agent": true, "custom_downloading_agent": "", diff --git a/backend/config/encrypted.json b/backend/config/encrypted.json index 0b1ce37..fd2833f 100644 --- a/backend/config/encrypted.json +++ b/backend/config/encrypted.json @@ -28,6 +28,12 @@ "default_theme": "default", "allow_theme_change": true }, + "Subscriptions": { + "allow_subscriptions": true, + "subscriptions_base_path": "subscriptions/", + "subscriptions_check_interval": "300", + "subscriptions_use_youtubedl_archive": true + }, "Advanced": { "use_default_downloading_agent": true, "custom_downloading_agent": "", diff --git a/backend/consts.js b/backend/consts.js index 19590e6..3c87c41 100644 --- a/backend/consts.js +++ b/backend/consts.js @@ -56,7 +56,6 @@ let CONFIG_ITEMS = { 'key': 'ytdl_allow_multi_download_mode', 'path': 'YoutubeDLMaterial.Extra.allow_multi_download_mode' }, - // API 'ytdl_use_youtube_api': { @@ -78,6 +77,28 @@ let CONFIG_ITEMS = { 'path': 'YoutubeDLMaterial.Themes.allow_theme_change' }, + // Subscriptions + 'ytdl_allow_subscriptions': { + 'key': 'ytdl_allow_subscriptions', + 'path': 'YoutubeDLMaterial.Subscriptions.allow_subscriptions' + }, + 'ytdl_subscriptions_base_path': { + 'key': 'ytdl_subscriptions_base_path', + 'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_base_path' + }, + 'ytdl_subscriptions_check_interval': { + 'key': 'ytdl_subscriptions_check_interval', + 'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval' + }, + 'ytdl_subscriptions_check_interval': { + 'key': 'ytdl_subscriptions_check_interval', + 'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_check_interval' + }, + 'ytdl_subscriptions_use_youtubedl_archive': { + 'key': 'ytdl_use_youtubedl_archive', + 'path': 'YoutubeDLMaterial.Subscriptions.subscriptions_use_youtubedl_archive' + }, + // Advanced 'ytdl_use_default_downloading_agent': { 'key': 'ytdl_use_default_downloading_agent', diff --git a/backend/package.json b/backend/package.json index ad85567..6da5631 100644 --- a/backend/package.json +++ b/backend/package.json @@ -26,6 +26,7 @@ "express": "^4.17.1", "lowdb": "^1.0.0", "shortid": "^2.2.15", + "uuidv4": "^6.0.6", "youtube-dl": "^3.0.2" } } diff --git a/backend/subscriptions.js b/backend/subscriptions.js new file mode 100644 index 0000000..65a2b22 --- /dev/null +++ b/backend/subscriptions.js @@ -0,0 +1,203 @@ +const low = require('lowdb') +const FileSync = require('lowdb/adapters/FileSync') + +var fs = require('fs'); +const { uuid } = require('uuidv4'); +var path = require('path'); + +var youtubedl = require('youtube-dl'); +const config_api = require('./config'); + +const adapter = new FileSync('db.json'); +const db = low(adapter) + +let debugMode = process.env.YTDL_MODE === 'debug'; + +async function subscribe(sub) { + const result_obj = { + success: false, + error: '' + }; + return new Promise(async resolve => { + // sub should just have url and name. here we will get isPlaylist and path + sub.isPlaylist = sub.url.includes('playlist'); + + if (db.get('subscriptions').find({url: sub.url}).value()) { + console.log('Sub already exists'); + result_obj.error = 'Subcription with URL ' + sub.url + ' already exists!'; + resolve(result_obj); + return; + } + + // add sub to db + db.get('subscriptions').push(sub).write(); + + await getVideosForSub(sub); + result_obj.success = true; + result_obj.sub = sub; + resolve(result_obj); + }); + +} + +async function unsubscribe(sub, deleteMode) { + return new Promise(async resolve => { + const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + let result_obj = { success: false, error: '' }; + + let id = sub.id; + db.get('subscriptions').remove({id: id}).write(); + + const appendedBasePath = getAppendedBasePath(sub, basePath); + if (deleteMode && fs.existsSync(appendedBasePath)) { + if (sub.archive && fs.existsSync(sub.archive)) { + const archive_file_path = path.join(sub.archive, 'archive.txt'); + // deletes archive if it exists + if (fs.existsSync(archive_file_path)) { + fs.unlinkSync(archive_file_path); + } + fs.rmdirSync(sub.archive); + } + deleteFolderRecursive(appendedBasePath); + } + }); + +} + +async function getVideosForSub(sub) { + return new Promise(resolve => { + const basePath = config_api.getConfigItem('ytdl_subscriptions_base_path'); + const useArchive = config_api.getConfigItem('ytdl_subscriptions_use_youtubedl_archive'); + + const appendedBasePath = basePath + (sub.isPlaylist ? 'playlists/%(playlist_title)s' : 'channels/%(uploader)s'); + + let downloadConfig = ['-o', appendedBasePath + '/%(title)s.mp4', '-f', 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/mp4', '-ciw', '--write-annotations', '--write-thumbnail', '--write-info-json', '--print-json']; + + if (sub.timerange) { + downloadConfig.push('--dateafter', sub.timerange); + } + + let archive_dir = null; + let archive_path = null; + let usingTempArchive = false; + + if (useArchive) { + if (sub.archive) { + archive_dir = sub.archive; + archive_path = path.join(archive_dir, 'archive.txt') + } else { + usingTempArchive = true; + + // set temporary archive + archive_dir = basePath + 'archives/' + sub.id; + archive_path = path.join(archive_dir, sub.id + '.txt'); + + // create temporary dir and archive txt + if (!fs.existsSync(archive_dir)) { + fs.mkdirSync(archive_dir); + fs.closeSync(fs.openSync(archive_path, 'w')); + } + } + downloadConfig.push('--download-archive', archive_path); + } + + // get videos + youtubedl.exec(sub.url, downloadConfig, {}, function(err, output) { + if (debugMode) { + console.log('Subscribe: got videos for subscription ' + sub.name); + } + if (err) { + console.log(err.stderr); + resolve(false); + } else if (output) { + if (output.length === 0) { + if (debugMode) console.log('No additional videos to download for ' + sub.name); + } + for (let i = 0; i < output.length; i++) { + let output_json = null; + try { + output_json = JSON.parse(output[i]); + } catch(e) { + output_json = null; + } + if (!output_json) { + continue; + } + + if (!sub.name && output_json) { + sub.name = sub.isPlaylist ? output_json.playlist_title : output_json.uploader; + // if it's now valid, update + if (sub.name) { + db.get('subscriptions').find({id: sub.id}).assign({name: sub.name}).write(); + } + } + + if (usingTempArchive && !sub.archive && sub.name) { + let new_archive_dir = basePath + 'archives/' + sub.name; + + // TODO: clean up, code looks ugly + if (fs.existsSync(new_archive_dir)) { + if (fs.existsSync(new_archive_dir + '/archive.txt')) { + console.log('INFO: Archive file already exists. Rewriting archive.'); + fs.unlinkSync(new_archive_dir + '/archive.txt') + } + } else { + // creates archive directory for subscription + fs.mkdirSync(new_archive_dir); + } + + // moves archive + fs.copyFileSync(archive_path, new_archive_dir + '/archive.txt'); + + // updates subscription + sub.archive = new_archive_dir; + db.get('subscriptions').find({id: sub.id}).assign({archive: new_archive_dir}).write(); + + // remove temporary archive directory + fs.unlinkSync(archive_path); + fs.rmdirSync(archive_dir); + } + } + resolve(true); + } + }); + }); +} + +function getAllSubscriptions() { + const subscriptions = db.get('subscriptions').value(); + return subscriptions; +} + +function getSubscription(subID) { + return db.get('subscriptions').find({id: subID}).value(); +} + +// helper functions + +function getAppendedBasePath(sub, base_path) { + return base_path + (sub.isPlaylist ? 'playlists/' : 'channels/') + sub.name; +} + +// https://stackoverflow.com/a/32197381/8088021 +const deleteFolderRecursive = function(folder_to_delete) { + if (fs.existsSync(folder_to_delete)) { + fs.readdirSync(folder_to_delete).forEach((file, index) => { + const curPath = path.join(folder_to_delete, file); + if (fs.lstatSync(curPath).isDirectory()) { // recurse + deleteFolderRecursive(curPath); + } else { // delete file + fs.unlinkSync(curPath); + } + }); + fs.rmdirSync(folder_to_delete); + } + }; + +module.exports = { + getSubscription : getSubscription, + getAllSubscriptions: getAllSubscriptions, + subscribe : subscribe, + unsubscribe : unsubscribe, + getVideosForSub : getVideosForSub +} diff --git a/backend/youtube-dl.exe b/backend/youtube-dl.exe index 0ccb93a..3e4ebeb 100644 Binary files a/backend/youtube-dl.exe and b/backend/youtube-dl.exe differ diff --git a/src/app/app-routing.module.ts b/src/app/app-routing.module.ts index c9d3181..c4cce48 100644 --- a/src/app/app-routing.module.ts +++ b/src/app/app-routing.module.ts @@ -2,9 +2,13 @@ import { NgModule } from '@angular/core'; import { RouterModule, Routes } from '@angular/router'; import { MainComponent } from './main/main.component'; import { PlayerComponent } from './player/player.component'; +import { SubscriptionsComponent } from './subscriptions/subscriptions.component'; +import { SubscriptionComponent } from './subscription/subscription/subscription.component'; const routes: Routes = [ { path: 'home', component: MainComponent }, { path: 'player', component: PlayerComponent}, + { path: 'subscriptions', component: SubscriptionsComponent }, + { path: 'subscription', component: SubscriptionComponent }, { path: '', redirectTo: '/home', pathMatch: 'full' }, ]; diff --git a/src/app/app.component.html b/src/app/app.component.html index fc7da91..426a303 100644 --- a/src/app/app.component.html +++ b/src/app/app.component.html @@ -1,17 +1,32 @@ -
- -
-
- +
+
+ +
+
+ + +
+
+
{{topBarTitle}}
+
+
+ +
-
-
{{topBarTitle}}
-
-
- -
-
- + +
- +
+ + + + Home + Subscriptions + + + + + + +
\ No newline at end of file diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 91a16c6..add0194 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -4,7 +4,7 @@ import {FileCardComponent} from './file-card/file-card.component'; import { Observable } from 'rxjs/Observable'; import {FormControl, Validators} from '@angular/forms'; import {BrowserAnimationsModule} from '@angular/platform-browser/animations'; -import {MatSnackBar} from '@angular/material'; +import {MatSnackBar, MatSidenav} from '@angular/material'; import { saveAs } from 'file-saver'; import 'rxjs/add/observable/of'; import 'rxjs/add/operator/mapTo'; @@ -15,7 +15,7 @@ import 'rxjs/add/operator/debounceTime' import 'rxjs/add/operator/do' import 'rxjs/add/operator/switch' import { YoutubeSearchService, Result } from './youtube-search.service'; -import { Router } from '@angular/router'; +import { Router, NavigationStart } from '@angular/router'; import { OverlayContainer } from '@angular/cdk/overlay'; import { THEMES_CONFIG } from '../themes'; @@ -34,11 +34,18 @@ export class AppComponent implements OnInit { defaultTheme = null; allowThemeChange = null; - @ViewChild('urlinput', { read: ElementRef, static: false }) urlInput: ElementRef; + @ViewChild('sidenav', {static: false}) sidenav: MatSidenav; + navigator: string = null; constructor(public postsService: PostsService, public snackBar: MatSnackBar, public router: Router, public overlayContainer: OverlayContainer, private elementRef: ElementRef) { + this.navigator = localStorage.getItem('player_navigator'); + // runs on navigate, captures the route that navigated to the player (if needed) + this.router.events.subscribe((e) => { if (e instanceof NavigationStart) { + this.navigator = localStorage.getItem('player_navigator'); + } }); + // loading config this.postsService.loadNavItems().subscribe(res => { // loads settings const result = !this.postsService.debugMode ? res['config_file'] : res; @@ -57,6 +64,10 @@ export class AppComponent implements OnInit { } + toggleSidenav() { + this.sidenav.toggle(); + } + // theme stuff setTheme(theme) { @@ -115,7 +126,11 @@ onSetTheme(theme, old_theme) { goBack() { - this.router.navigate(['/home']); + if (!this.navigator) { + this.router.navigate(['/home']); + } else { + this.router.navigateByUrl(this.navigator); + } } } diff --git a/src/app/app.module.ts b/src/app/app.module.ts index 6ffecfe..928b4d4 100644 --- a/src/app/app.module.ts +++ b/src/app/app.module.ts @@ -5,7 +5,9 @@ import {MatNativeDateModule, MatRadioModule, MatInputModule, MatButtonModule, Ma MatProgressBarModule, MatExpansionModule, MatProgressSpinnerModule, MatButtonToggleModule, - MatDialogModule} from '@angular/material'; + MatDialogModule, + MatRippleModule, + MatMenuModule} from '@angular/material'; import {DragDropModule} from '@angular/cdk/drag-drop'; import {FormsModule, ReactiveFormsModule} from '@angular/forms'; import { AppComponent } from './app.component'; @@ -28,6 +30,11 @@ import { NgxContentLoadingModule } from 'ngx-content-loading'; import { audioFilesMouseHovering, videoFilesMouseHovering, audioFilesOpened, videoFilesOpened } from './main/main.component'; import { CreatePlaylistComponent } from './create-playlist/create-playlist.component'; import { DownloadItemComponent } from './download-item/download-item.component'; +import { SubscriptionsComponent } from './subscriptions/subscriptions.component'; +import { SubscribeDialogComponent } from './dialogs/subscribe-dialog/subscribe-dialog.component'; +import { SubscriptionComponent } from './subscription//subscription/subscription.component'; +import { SubscriptionFileCardComponent } from './subscription/subscription-file-card/subscription-file-card.component'; +import { SubscriptionInfoDialogComponent } from './dialogs/subscription-info-dialog/subscription-info-dialog.component'; export function isVisible({ event, element, scrollContainer, offset }: IsVisibleProps) { return (element.id === 'video' ? videoFilesMouseHovering || videoFilesOpened : audioFilesMouseHovering || audioFilesOpened); @@ -41,7 +48,12 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible PlayerComponent, InputDialogComponent, CreatePlaylistComponent, - DownloadItemComponent + DownloadItemComponent, + SubscriptionsComponent, + SubscribeDialogComponent, + SubscriptionComponent, + SubscriptionFileCardComponent, + SubscriptionInfoDialogComponent ], imports: [ BrowserModule, @@ -67,6 +79,8 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible MatProgressBarModule, MatProgressSpinnerModule, MatButtonToggleModule, + MatRippleModule, + MatMenuModule, MatDialogModule, DragDropModule, VgCoreModule, @@ -80,7 +94,9 @@ export function isVisible({ event, element, scrollContainer, offset }: IsVisible ], entryComponents: [ InputDialogComponent, - CreatePlaylistComponent + CreatePlaylistComponent, + SubscribeDialogComponent, + SubscriptionInfoDialogComponent ], providers: [PostsService], bootstrap: [AppComponent] diff --git a/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.html b/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.html new file mode 100644 index 0000000..7e8d004 --- /dev/null +++ b/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.html @@ -0,0 +1,43 @@ +

Subscribe to playlist or channel

+ + +
+
+
+ + + The playlist or channel URL + +
+
+ + + This is optional + +
+
+ Download all uploads +
+
+ Download videos uploaded in the last + + + + + + {{time_unit + (timerange_amount === 1 ? '' : 's')}} + + +
+
+
+
+ + + + + +
+ +
+
\ No newline at end of file diff --git a/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.scss b/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.scss new file mode 100644 index 0000000..809e4ac --- /dev/null +++ b/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.scss @@ -0,0 +1,8 @@ +.unit-select { + width: 75px; + margin-left: 20px; +} + +.mat-spinner { + margin-left: 5%; +} diff --git a/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.spec.ts b/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.spec.ts new file mode 100644 index 0000000..94d1fe1 --- /dev/null +++ b/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SubscribeDialogComponent } from './subscribe-dialog.component'; + +describe('SubscribeDialogComponent', () => { + let component: SubscribeDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SubscribeDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SubscribeDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.ts b/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.ts new file mode 100644 index 0000000..129da4f --- /dev/null +++ b/src/app/dialogs/subscribe-dialog/subscribe-dialog.component.ts @@ -0,0 +1,69 @@ +import { Component, OnInit } from '@angular/core'; +import { MatSnackBar, MatDialogRef } from '@angular/material'; +import { PostsService } from 'app/posts.services'; + +@Component({ + selector: 'app-subscribe-dialog', + templateUrl: './subscribe-dialog.component.html', + styleUrls: ['./subscribe-dialog.component.scss'] +}) +export class SubscribeDialogComponent implements OnInit { + // inputs + timerange_amount; + timerange_unit = 'days'; + download_all = true; + url = null; + name = null; + + // state + subscribing = false; + + time_units = [ + 'day', + 'week', + 'month', + 'year' + ] + + constructor(private postsService: PostsService, + private snackBar: MatSnackBar, + public dialogRef: MatDialogRef) { } + + ngOnInit() { + } + + subscribeClicked() { + if (this.url && this.url !== '') { + // timerange must be specified if download_all is false + if (!this.download_all && !this.timerange_amount) { + this.openSnackBar('You must specify an amount of time'); + return; + } + this.subscribing = true; + + let timerange = null; + if (!this.download_all) { + timerange = 'now-' + this.timerange_amount.toString() + this.timerange_unit; + } + + this.postsService.createSubscription(this.url, this.name, timerange).subscribe(res => { + this.subscribing = false; + if (res['new_sub']) { + this.dialogRef.close(res['new_sub']); + } else { + if (res['error']) { + this.openSnackBar('ERROR: ' + res['error']); + } + this.dialogRef.close(); + } + }); + } + } + + public openSnackBar(message: string, action = '') { + this.snackBar.open(message, action, { + duration: 2000, + }); + } + +} diff --git a/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.html b/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.html new file mode 100644 index 0000000..b0ccf22 --- /dev/null +++ b/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.html @@ -0,0 +1,10 @@ +

{{sub.name}}

+ + + Type: {{(sub.isPlaylist ? 'Playlist' : 'Channel')}} + + + + + + \ No newline at end of file diff --git a/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.scss b/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.spec.ts b/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.spec.ts new file mode 100644 index 0000000..45fa822 --- /dev/null +++ b/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SubscriptionInfoDialogComponent } from './subscription-info-dialog.component'; + +describe('SubscriptionInfoDialogComponent', () => { + let component: SubscriptionInfoDialogComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SubscriptionInfoDialogComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SubscriptionInfoDialogComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.ts b/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.ts new file mode 100644 index 0000000..ee004d3 --- /dev/null +++ b/src/app/dialogs/subscription-info-dialog/subscription-info-dialog.component.ts @@ -0,0 +1,32 @@ +import { Component, OnInit, Inject } from '@angular/core'; +import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material'; +import { PostsService } from 'app/posts.services'; + +@Component({ + selector: 'app-subscription-info-dialog', + templateUrl: './subscription-info-dialog.component.html', + styleUrls: ['./subscription-info-dialog.component.scss'] +}) +export class SubscriptionInfoDialogComponent implements OnInit { + + sub = null; + unsubbedEmitter = null; + + constructor(public dialogRef: MatDialogRef, + @Inject(MAT_DIALOG_DATA) public data: any, private postsService: PostsService) { } + + ngOnInit() { + if (this.data) { + this.sub = this.data.sub; + this.unsubbedEmitter = this.data.unsubbedEmitter; + } + } + + unsubscribe() { + this.postsService.unsubscribe(this.sub, true).subscribe(res => { + this.unsubbedEmitter.emit(true); + this.dialogRef.close(); + }); + } + +} diff --git a/src/app/main/main.component.ts b/src/app/main/main.component.ts index 3b3e640..4a8d950 100644 --- a/src/app/main/main.component.ts +++ b/src/app/main/main.component.ts @@ -372,6 +372,7 @@ export class MainComponent implements OnInit { this.downloading_content[type][playlistID] = true; this.downloadPlaylist(playlist.fileNames, type, playlist.name, playlistID); } else { + localStorage.setItem('player_navigator', this.router.url); const fileNames = playlist.fileNames; this.router.navigate(['/player', {fileNames: fileNames.join('|nvr|'), type: type, id: playlistID}]); } @@ -444,6 +445,7 @@ export class MainComponent implements OnInit { this.downloadAudioFile(decodeURI(name)); } } else { + localStorage.setItem('player_navigator', this.router.url); if (is_playlist) { this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'audio'}]); } else { @@ -481,6 +483,7 @@ export class MainComponent implements OnInit { this.downloadVideoFile(decodeURI(name)); } } else { + localStorage.setItem('player_navigator', this.router.url); if (is_playlist) { this.router.navigate(['/player', {fileNames: name.join('|nvr|'), type: 'video'}]); } else { diff --git a/src/app/player/player.component.ts b/src/app/player/player.component.ts index d8e3466..41dccb5 100644 --- a/src/app/player/player.component.ts +++ b/src/app/player/player.component.ts @@ -31,16 +31,19 @@ export class PlayerComponent implements OnInit { // params fileNames: string[]; type: string; + id = null; // used for playlists (not subscription) + subscriptionName = null; + subPlaylist = null; baseStreamPath = null; audioFolderPath = null; videoFolderPath = null; + subscriptionFolderPath = null; + innerWidth: number; downloading = false; - id = null; - @HostListener('window:resize', ['$event']) onResize(event) { this.innerWidth = window.innerWidth; @@ -52,6 +55,8 @@ export class PlayerComponent implements OnInit { this.fileNames = this.route.snapshot.paramMap.get('fileNames').split('|nvr|'); this.type = this.route.snapshot.paramMap.get('type'); this.id = this.route.snapshot.paramMap.get('id'); + this.subscriptionName = this.route.snapshot.paramMap.get('subscriptionName'); + this.subPlaylist = this.route.snapshot.paramMap.get('subPlaylist'); // loading config this.postsService.loadNavItems().subscribe(res => { // loads settings @@ -59,6 +64,7 @@ export class PlayerComponent implements OnInit { this.baseStreamPath = this.postsService.path; this.audioFolderPath = result['YoutubeDLMaterial']['Downloader']['path-audio']; this.videoFolderPath = result['YoutubeDLMaterial']['Downloader']['path-video']; + this.subscriptionFolderPath = result['YoutubeDLMaterial']['Subscriptions']['subscriptions_base_path']; let fileType = null; @@ -66,15 +72,27 @@ export class PlayerComponent implements OnInit { fileType = 'audio/mp3'; } else if (this.type === 'video') { fileType = 'video/mp4'; + } else if (this.type === 'subscription') { + // only supports mp4 for now + fileType = 'video/mp4'; } else { // error - console.error('Must have valid file type! Use \'audio\' or \video\''); + console.error('Must have valid file type! Use \'audio\', \'video\', or \'subscription\'.'); } for (let i = 0; i < this.fileNames.length; i++) { const fileName = this.fileNames[i]; - const baseLocation = (this.type === 'audio') ? this.audioFolderPath : this.videoFolderPath; - const fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName); + let baseLocation = null; + let fullLocation = null; + if (!this.subscriptionName) { + baseLocation = this.type + '/'; + fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName); + } else { + // default to video but include subscription name param + baseLocation = 'video/'; + fullLocation = this.baseStreamPath + baseLocation + encodeURIComponent(fileName) + '?subName=' + this.subscriptionName + + '&subPlaylist=' + this.subPlaylist; + } // if it has a slash (meaning it's in a directory), only get the file name for the label let label = null; const decodedName = decodeURIComponent(fileName); diff --git a/src/app/posts.services.ts b/src/app/posts.services.ts index 08fa9a0..2b3e31e 100644 --- a/src/app/posts.services.ts +++ b/src/app/posts.services.ts @@ -136,6 +136,22 @@ export class PostsService { removePlaylist(playlistID, type) { return this.http.post(this.path + 'deletePlaylist', {playlistID: playlistID, type: type}); } + + createSubscription(url, name, timerange = null) { + return this.http.post(this.path + 'subscribe', {url: url, name: name, timerange: timerange}) + } + + unsubscribe(sub, deleteMode = false) { + return this.http.post(this.path + 'unsubscribe', {sub: sub, deleteMode: deleteMode}) + } + + getSubscription(id) { + return this.http.post(this.path + 'getSubscription', {id: id}); + } + + getAllSubscriptions() { + return this.http.post(this.path + 'getAllSubscriptions', {}); + } } diff --git a/src/app/subscription/subscription-file-card/subscription-file-card.component.html b/src/app/subscription/subscription-file-card/subscription-file-card.component.html new file mode 100644 index 0000000..da0a6ca --- /dev/null +++ b/src/app/subscription/subscription-file-card/subscription-file-card.component.html @@ -0,0 +1,17 @@ +
+ + + + + + + +
+
+ Thumbnail +
+ + {{file.title}} +
+
+
diff --git a/src/app/subscription/subscription-file-card/subscription-file-card.component.scss b/src/app/subscription/subscription-file-card/subscription-file-card.component.scss new file mode 100644 index 0000000..a0e5f36 --- /dev/null +++ b/src/app/subscription/subscription-file-card/subscription-file-card.component.scss @@ -0,0 +1,69 @@ +.example-card { + width: 200px; + height: 200px; + padding: 0px; + cursor: pointer; + } + + .menuButton { + right: 0px; + top: -1px; + position: absolute; + z-index: 999; + + } + + /* Coerce the icon container away from display:inline */ + .mat-icon-button .mat-button-wrapper { + display: flex; + justify-content: center; + } + + .image { + width: 200px; + height: 112.5px; + object-fit: cover; + } + + .example-full-width-height { + width: 100%; + height: 100% + } + + .centered { + margin: 0 auto; + top: 50%; + left: 50%; + } + + .img-div { + max-height: 80px; + padding: 0px; + margin: 32px 0px 0px -5px; + width: calc(100% + 5px + 5px); + } + + .max-two-lines { + display: -webkit-box; + display: -moz-box; + max-height: 2.4em; + line-height: 1.2em; + overflow: hidden; + text-overflow: ellipsis; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; + bottom: 5px; + position: absolute; + } + + @media (max-width: 576px){ + + .example-card { + width: 175px !important; + } + + .image { + width: 175px; + } + + } \ No newline at end of file diff --git a/src/app/subscription/subscription-file-card/subscription-file-card.component.spec.ts b/src/app/subscription/subscription-file-card/subscription-file-card.component.spec.ts new file mode 100644 index 0000000..ccb2763 --- /dev/null +++ b/src/app/subscription/subscription-file-card/subscription-file-card.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SubscriptionFileCardComponent } from './subscription-file-card.component'; + +describe('SubscriptionFileCardComponent', () => { + let component: SubscriptionFileCardComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SubscriptionFileCardComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SubscriptionFileCardComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/subscription/subscription-file-card/subscription-file-card.component.ts b/src/app/subscription/subscription-file-card/subscription-file-card.component.ts new file mode 100644 index 0000000..d17baa2 --- /dev/null +++ b/src/app/subscription/subscription-file-card/subscription-file-card.component.ts @@ -0,0 +1,56 @@ +import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; +import { Observable, Subject } from 'rxjs'; +import { MatSnackBar } from '@angular/material'; +import { Router } from '@angular/router'; + +@Component({ + selector: 'app-subscription-file-card', + templateUrl: './subscription-file-card.component.html', + styleUrls: ['./subscription-file-card.component.scss'] +}) +export class SubscriptionFileCardComponent implements OnInit { + image_errored = false; + image_loaded = false; + + scrollSubject; + scrollAndLoad; + + @Input() file; + + @Output() goToFileEmit = new EventEmitter(); + + constructor(private snackBar: MatSnackBar) { + this.scrollSubject = new Subject(); + this.scrollAndLoad = Observable.merge( + Observable.fromEvent(window, 'scroll'), + this.scrollSubject + ); + } + + ngOnInit() { + + } + + onImgError(event) { + this.image_errored = true; + } + + onHoverResponse() { + this.scrollSubject.next(); + } + + imageLoaded(loaded) { + this.image_loaded = true; + } + + goToFile() { + this.goToFileEmit.emit(this.file.title); + } + + public openSnackBar(message: string, action: string) { + this.snackBar.open(message, action, { + duration: 2000, + }); + } + +} diff --git a/src/app/subscription/subscription/subscription.component.html b/src/app/subscription/subscription/subscription.component.html new file mode 100644 index 0000000..9fa386b --- /dev/null +++ b/src/app/subscription/subscription/subscription.component.html @@ -0,0 +1,20 @@ +
+ +
+

+ {{subscription.name}} +

+
+ +
+ +
+

Videos

+
+
+
+ +
+
+
+
\ No newline at end of file diff --git a/src/app/subscription/subscription/subscription.component.scss b/src/app/subscription/subscription/subscription.component.scss new file mode 100644 index 0000000..0f2927c --- /dev/null +++ b/src/app/subscription/subscription/subscription.component.scss @@ -0,0 +1,9 @@ +.sub-file-col { + max-width: 240px; +} + +.back-button { + float: left; + position: absolute; + left: 15px; +} \ No newline at end of file diff --git a/src/app/subscription/subscription/subscription.component.spec.ts b/src/app/subscription/subscription/subscription.component.spec.ts new file mode 100644 index 0000000..a4a2203 --- /dev/null +++ b/src/app/subscription/subscription/subscription.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SubscriptionComponent } from './subscription.component'; + +describe('SubscriptionComponent', () => { + let component: SubscriptionComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SubscriptionComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SubscriptionComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/subscription/subscription/subscription.component.ts b/src/app/subscription/subscription/subscription.component.ts new file mode 100644 index 0000000..50c2b35 --- /dev/null +++ b/src/app/subscription/subscription/subscription.component.ts @@ -0,0 +1,44 @@ +import { Component, OnInit } from '@angular/core'; +import { PostsService } from 'app/posts.services'; +import { ActivatedRoute, Router } from '@angular/router'; + +@Component({ + selector: 'app-subscription', + templateUrl: './subscription.component.html', + styleUrls: ['./subscription.component.scss'] +}) +export class SubscriptionComponent implements OnInit { + + id = null; + subscription = null; + files: any[] = null; + + constructor(private postsService: PostsService, private route: ActivatedRoute, private router: Router) { } + + ngOnInit() { + if (this.route.snapshot.paramMap.get('id')) { + this.id = this.route.snapshot.paramMap.get('id'); + + this.getSubscription(); + } + } + + goBack() { + this.router.navigate(['/subscriptions']); + } + + getSubscription() { + this.postsService.getSubscription(this.id).subscribe(res => { + this.subscription = res['subscription']; + console.log(res['files']); + this.files = res['files']; + }); + } + + goToFile(name) { + localStorage.setItem('player_navigator', this.router.url); + this.router.navigate(['/player', {fileNames: name, type: 'subscription', subscriptionName: this.subscription.name, + subPlaylist: this.subscription.isPlaylist}]); + } + +} diff --git a/src/app/subscriptions/subscriptions.component.html b/src/app/subscriptions/subscriptions.component.html new file mode 100644 index 0000000..f4bb1ca --- /dev/null +++ b/src/app/subscriptions/subscriptions.component.html @@ -0,0 +1,54 @@ +
+ +

Your subscriptions

+ + +
+ +

Channels

+ + + + {{ sub.name }} +
+ + + +
+
+ +
+
+ +
+

You have no channel subscriptions.

+
+ +

Playlists

+ + + + {{ sub.name }} +
+ + + +
+
+ +
+
+ +
+

You have no playlist subscriptions.

+
+ +
+ +
+ + \ No newline at end of file diff --git a/src/app/subscriptions/subscriptions.component.scss b/src/app/subscriptions/subscriptions.component.scss new file mode 100644 index 0000000..9ed3fd7 --- /dev/null +++ b/src/app/subscriptions/subscriptions.component.scss @@ -0,0 +1,27 @@ +.add-subscription-button { + position: fixed; + bottom: 30px; + right: 30px; +} + +.subscription-card { + height: 200px; + width: 300px; +} + +.content-loading-div { + position: absolute; + width: 200px; + height: 50px; + bottom: -18px; +} + +.a-list-item { + height: 48px; + padding-top: 12px !important; +} + +.sub-nav-list { + margin: 0 auto; + width: 80%; +} \ No newline at end of file diff --git a/src/app/subscriptions/subscriptions.component.spec.ts b/src/app/subscriptions/subscriptions.component.spec.ts new file mode 100644 index 0000000..205dcf4 --- /dev/null +++ b/src/app/subscriptions/subscriptions.component.spec.ts @@ -0,0 +1,25 @@ +import { async, ComponentFixture, TestBed } from '@angular/core/testing'; + +import { SubscriptionsComponent } from './subscriptions.component'; + +describe('SubscriptionsComponent', () => { + let component: SubscriptionsComponent; + let fixture: ComponentFixture; + + beforeEach(async(() => { + TestBed.configureTestingModule({ + declarations: [ SubscriptionsComponent ] + }) + .compileComponents(); + })); + + beforeEach(() => { + fixture = TestBed.createComponent(SubscriptionsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/subscriptions/subscriptions.component.ts b/src/app/subscriptions/subscriptions.component.ts new file mode 100644 index 0000000..f489239 --- /dev/null +++ b/src/app/subscriptions/subscriptions.component.ts @@ -0,0 +1,93 @@ +import { Component, OnInit, EventEmitter } from '@angular/core'; +import { MatDialog, MatSnackBar } from '@angular/material'; +import { SubscribeDialogComponent } from 'app/dialogs/subscribe-dialog/subscribe-dialog.component'; +import { PostsService } from 'app/posts.services'; +import { Router } from '@angular/router'; +import { SubscriptionInfoDialogComponent } from 'app/dialogs/subscription-info-dialog/subscription-info-dialog.component'; + +@Component({ + selector: 'app-subscriptions', + templateUrl: './subscriptions.component.html', + styleUrls: ['./subscriptions.component.scss'] +}) +export class SubscriptionsComponent implements OnInit { + + playlist_subscriptions = []; + channel_subscriptions = []; + subscriptions = null; + + subscriptions_loading = false; + + constructor(private dialog: MatDialog, private postsService: PostsService, private router: Router, private snackBar: MatSnackBar) { } + + ngOnInit() { + this.getSubscriptions(); + } + + getSubscriptions() { + this.subscriptions_loading = true; + this.subscriptions = []; + this.channel_subscriptions = []; + this.playlist_subscriptions = []; + this.postsService.getAllSubscriptions().subscribe(res => { + this.subscriptions_loading = false; + this.subscriptions = res['subscriptions']; + + for (let i = 0; i < this.subscriptions.length; i++) { + const sub = this.subscriptions[i]; + + // parse subscriptions into channels and playlists + if (sub.isPlaylist) { + this.playlist_subscriptions.push(sub); + } else { + this.channel_subscriptions.push(sub); + } + } + }); + } + + goToSubscription(sub) { + this.router.navigate(['/subscription', {id: sub.id}]); + } + + openSubscribeDialog() { + const dialogRef = this.dialog.open(SubscribeDialogComponent, { + maxWidth: 500, + width: '80vw' + }); + + dialogRef.afterClosed().subscribe(result => { + if (result) { + if (result.isPlaylist) { + this.playlist_subscriptions.push(result); + } else { + this.channel_subscriptions.push(result); + } + } + }); + } + + showSubInfo(sub) { + const unsubbedEmitter = new EventEmitter(); + const dialogRef = this.dialog.open(SubscriptionInfoDialogComponent, { + data: { + sub: sub, + unsubbedEmitter: unsubbedEmitter + } + }); + unsubbedEmitter.subscribe(success => { + if (success) { + this.openSnackBar(`${sub.name} successfully deleted!`) + this.getSubscriptions(); + } + }) + } + + // snackbar helper + public openSnackBar(message: string, action = '') { + this.snackBar.open(message, action, { + duration: 2000, + }); + } + +} diff --git a/src/assets/default.json b/src/assets/default.json index 6d9ab14..3b0e85a 100644 --- a/src/assets/default.json +++ b/src/assets/default.json @@ -28,6 +28,12 @@ "default_theme": "default", "allow_theme_change": true }, + "Subscriptions": { + "allow_subscriptions": true, + "subscriptions_base_path": "subscriptions/", + "subscriptions_check_interval": "300", + "subscriptions_use_youtubedl_archive": true + }, "Advanced": { "use_default_downloading_agent": true, "custom_downloading_agent": "", diff --git a/src/favicon.ico b/src/favicon.ico index 8b74b2c..c3aeeba 100644 Binary files a/src/favicon.ico and b/src/favicon.ico differ diff --git a/src/themes.ts b/src/themes.ts index 8fea9ee..cf9aa99 100644 --- a/src/themes.ts +++ b/src/themes.ts @@ -2,12 +2,14 @@ const THEMES_CONFIG = { 'default': { 'key': 'default', 'background_color': 'ghostwhite', + 'alternate_color': 'gray', 'css_label': 'default-theme', 'social_theme': 'material-light' }, 'dark': { 'key': 'dark', 'background_color': '#757575', + 'alternate_color': '#695959', 'css_label': 'dark-theme', 'social_theme': 'material-dark' },