├── .gitignore ├── README.md ├── app.js ├── assets ├── blank.png ├── fonts │ ├── manrope-bold.woff2 │ ├── manrope-light.woff2 │ ├── manrope-medium.woff2 │ ├── manrope-semibold.woff2 │ └── manrope-thin.woff2 ├── furniture.png ├── grid.png ├── grunge.png ├── icons │ ├── app │ │ ├── check.svg │ │ ├── close.svg │ │ ├── heart.svg │ │ ├── left.svg │ │ ├── list.svg │ │ ├── play.svg │ │ ├── right.svg │ │ ├── search.svg │ │ ├── settings.svg │ │ ├── signout.svg │ │ └── sync.svg │ ├── trakt │ │ ├── 128x128.png │ │ ├── 512x512.png │ │ ├── trakt.icns │ │ ├── trakt.ico │ │ ├── trakt.svg │ │ └── trakt_no_circle.png │ └── traktify │ │ ├── 128x128.png │ │ ├── 512x512.png │ │ ├── traktify.icns │ │ ├── traktify.ico │ │ └── traktify.svg ├── loading_placeholder.gif ├── loading_placeholder_nobg.gif ├── master.css ├── placeholder.png ├── placeholder_nobg.png └── previews │ ├── blank.png │ ├── furniture.png │ ├── grid.png │ └── grunge.png ├── config.json ├── def_config.json ├── docs ├── cache.md ├── config.md ├── episodes.md └── helpers.md ├── modules ├── api │ ├── cachers.js │ ├── getters.js │ └── requesters.js ├── cache.js ├── helper.js ├── presence.js ├── queue.js ├── request.js └── rpc.js ├── package-lock.json ├── package.json ├── pages ├── dashboard │ ├── index.html │ ├── index.js │ └── style.css ├── loading │ ├── index.html │ ├── index.js │ └── style.css ├── login │ ├── index.html │ ├── index.js │ └── style.css └── main.js └── prototype └── traktify.xd /.gitignore: -------------------------------------------------------------------------------- 1 | # system trash 2 | .DS_Store 3 | .stignore 4 | /**/.stfolder 5 | 6 | # unwanted trash 7 | *.bak 8 | *.log 9 | *.tmp 10 | 11 | /**/tmp 12 | 13 | # build folders 14 | /bin 15 | /out 16 | /target 17 | 18 | # node stuff 19 | /node_modules 20 | /packages 21 | logs 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | 26 | # runtime stuff 27 | pids 28 | *.pid 29 | *.seed 30 | *.pid.lock 31 | .npm 32 | 33 | # secrets 34 | *.real.* 35 | .env 36 | 37 | # cache 38 | /**/.cache 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 |

4 | 5 |

Traktify

6 | 7 | > A multi platform desktop app for trakt.tv 8 | 9 | ![quality][quality] 10 | ![size][size] 11 | ![top_lang][top_lang] 12 | ![electron][electron] 13 | 14 | Traktify provides super easy super fast access to your trakt.tv account. User experience is our top priority. 15 | 16 | Visit the project's website [here](https://codingbobby.xyz/traktify). 17 | 18 | 19 | ## Features 20 | So let us tell you what traktify can do—because thats what you really care about right? 21 | 22 | ### Fast-access dashboard 23 | ![screen_dashboard](https://i.imgur.com/XOTBUlz.png) 24 | Traktify's dashboard offers a clean overview of whats up next to watch for you. From here, you can add episodes you just watched to your history. The search panel allows blazing-fast access to the entire trakt database. Via shortcuts, filtering search results is made very easy. 25 | 26 | ### Keyboard shortcuts 27 | ![screen_search](https://i.imgur.com/8TTo3hg.png) 28 | If you're a keyboard orientated person, we've got you. Simple commands allow you to quickly jump through traktify's pages and panels. 29 | 30 | ### Customization 31 | ![screen_settings](https://i.imgur.com/GCv198t.png) 32 | A wide range of settings let you customize the look and feel of your app. You can apply different accent colors, background textures and more. 33 | 34 | ### Discord integration 35 | If you wish, you can let traktify show a beautiful rich-presence on your Discord profile. All you need to do is clicking a button. 36 | 37 | 38 | ## Getting started 39 | Traktify is currently at it's climax of development. You can dive in really soon. 40 | 41 | ### Requirements 42 | Traktify is an electron based app and thus requires a Windows 7 (and higher) or macOS 10.10 (and higher) machine to run on. You'll also need a [Node.js](https://nodejs.org/en/download/) installation. Traktify is tested on versions higher than `v10.10.0` but we recommend the latest `LTS` release. 43 | 44 | If you want to use the Discord Rich-Presence integration, you'll also have to install [Python](https://www.python.org/downloads/). 45 | 46 | 47 | ## Contributing 48 | Contributions are very welcome! To report issues and start pull requests, please use gihub's integrated systems. 49 | 50 | 51 | ## Credits 52 | - [Jean van Kasteel](https://github.com/vankasteelj): `trakt.tv` and `fanart.tv` 53 | - [Roy Riojas](https://github.com/royriojas): `flat-cache` 54 | 55 | 56 | ## Authors 57 | 58 | ### [Bumbleboss](https://github.com/Bumbleboss) 59 | - Founder 60 | - Frontend developer 61 | - Graphics designer 62 | 63 | ### [CodingBobby](https://github.com/CodingBobby) 64 | - Cofounder 65 | - Fullstack developer 66 | - Project manager 67 | 68 | 69 | 70 | [top_lang]: https://img.shields.io/github/languages/top/CodingBobby/traktify.svg?style=flat-square 71 | [quality]: https://img.shields.io/codacy/grade/a68c06c191d54df0879b854c05c2ea79/master.svg?style=flat-square 72 | [electron]: https://img.shields.io/github/package-json/dependency-version/CodingBobby/traktify/dev/electron.svg?style=flat-square 73 | [size]: https://img.shields.io/github/repo-size/CodingBobby/traktify.svg?style=flat-square 74 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | /*\ 2 | |*| TRAKTIFY 3 | |*| is a desktop app for trakt.tv 4 | |*| created by @CodingBobby and @Bumbleboss in 2019 5 | |*| using the trakt api with the trakt.js library 6 | |*| in an electron framework, 7 | |*| the current version is 0.1.1 8 | |*| from the 21th Feb. 2019 9 | \*/ 10 | 11 | // Uncomment this line for publishing! 12 | // process.env.NODE_ENV = 'production' 13 | 14 | let initTime = Date.now() 15 | 16 | // electron stuff 17 | const electron = require('electron') 18 | const windowStateKeeper = require('electron-window-state') 19 | const { 20 | app, 21 | BrowserWindow, 22 | Menu, 23 | shell, 24 | clipboard, 25 | dialog, 26 | ipcMain 27 | } = electron 28 | 29 | // file stuff 30 | const fs = require('fs') 31 | const path = require('path') 32 | const rimraf = require('rimraf') 33 | 34 | // api stuff 35 | const Trakt = require('trakt.tv') 36 | const Fanart = require('fanart.tv') 37 | const TvDB = require('node-tvdb') 38 | const TmDB = require('moviedb-promise') 39 | 40 | // request stuff 41 | const request = require('request') 42 | 43 | const { 44 | debugLog, inRange, shadeHexColor, clone, ipcChannels 45 | } = require('./modules/helper.js') 46 | global.debugLog = debugLog 47 | 48 | 49 | // important checks regarding required files 50 | let fatalError = false 51 | 52 | fs.exists('./config.json', ex => { 53 | if(!ex) { 54 | debugLog('error', 'config does not exist', new Error().stack) 55 | fatalError = true 56 | } 57 | }) 58 | 59 | if(process.env.trakt_id == undefined) envErr() 60 | if(process.env.trakt_secret == undefined) envErr() 61 | if(process.env.fanart_key == undefined) envErr() 62 | if(process.env.tmdb_key == undefined) envErr() 63 | if(process.env.tvdb_key == undefined) envErr() 64 | if(process.env.discord_key == undefined) envErr() 65 | 66 | function envErr() { 67 | debugLog('error', 'one or more env vars do not exist', new Error().stack) 68 | fatalError = true 69 | } 70 | 71 | 72 | // configuration and boolean checks that we need frequently 73 | // the config file will be used to save preferences the user can change 74 | // (like darkmode, behavior etc.) 75 | global.config = JSON.parse(fs.readFileSync("./config.json", "utf8")) 76 | let user = global.config.user 77 | 78 | // defining global variables that can be accessed from other scripts 79 | global.openExternal = shell.openExternal 80 | global.darwin = process.platform == 'darwin' 81 | 82 | // Comment out these lines when in production! Used as helpers to move around the app from the command line. 83 | global.loadDashboard = loadDashboard 84 | global.loadLogin = loadLogin 85 | 86 | // these are the api globals 87 | global.trakt 88 | global.fanart 89 | global.tvdb 90 | global.tmdb 91 | 92 | let window = null 93 | 94 | const traktOptions = { 95 | client_id: process.env.trakt_id, 96 | client_secret: process.env.trakt_secret 97 | } 98 | 99 | if(process.env.NODE_ENV !== 'production') { 100 | traktOptions.debug = true 101 | } 102 | 103 | // here we set some options we need later 104 | const windowOptions = { 105 | minWidth: 800, 106 | minHeight: 500, 107 | width: 900, 108 | height: 750, 109 | useContentSize: true, 110 | titleBarStyle: 'hidden', 111 | backgroundColor: '#242424', 112 | title: 'Traktify', 113 | icon: global.darwin ? path.join(__dirname, 'assets/icons/trakt/trakt.icns') 114 | : path.join(__dirname, 'assets/icons/trakt/tract.ico'), 115 | show: false, 116 | center: true, 117 | webPreferences: { 118 | experimentalFeatures: true 119 | } 120 | } 121 | 122 | // here we create a template for the main menu, to get the right shortcut, we check if we're running on darwin 123 | let menuTemplate = [{ 124 | label: 'App', 125 | submenu: [{ 126 | label: 'About', 127 | click() { 128 | shell.openExternal('https://github.com/CodingBobby/traktify') 129 | } 130 | }, { 131 | label: 'Quit Traktify', 132 | accelerator: global.darwin ? 'Command+Q' 133 | : 'Ctrl+Q', 134 | click() { 135 | app.quit() 136 | } 137 | }, { 138 | type: 'separator' 139 | }, { 140 | label: 'Reset Traktify', 141 | click() { 142 | dialog.showMessageBox({ 143 | type: 'question', 144 | title: 'Reset Traktify', 145 | message: 'Are you sure? This removes all data from Traktify and you have to login again.', 146 | buttons: ['alright', 'hell no'], 147 | defaultId: 1, 148 | normalizeAccessKeys: false 149 | }, button => { 150 | if(button == 0) { 151 | resetTraktify(true) 152 | } 153 | }) 154 | } 155 | }] 156 | }] 157 | 158 | // if the app is in development mode, these menu items will be pushed to the menu template 159 | if(process.env.NODE_ENV !== 'production') { 160 | menuTemplate.push({ 161 | label: 'Dev Tools', 162 | submenu: [{ 163 | label: 'Toggle Dev Tools', 164 | accelerator: global.darwin ? 'Command+I' 165 | : 'Ctrl+I', 166 | click(item, focusedWindow){ 167 | focusedWindow.toggleDevTools() 168 | } 169 | }, { 170 | label: 'Reload App', 171 | accelerator: global.darwin ? 'Command+R' 172 | : 'Ctrl+R', 173 | role: 'reload' 174 | }] 175 | }) 176 | } 177 | 178 | const mainMenu = Menu.buildFromTemplate(menuTemplate) 179 | 180 | // This function builds the app window, shows the correct page and handles window.on() events 181 | function build() { 182 | if(fatalError) { 183 | return process.crash() 184 | } 185 | 186 | debugLog('app', 'now building') 187 | let mainWindowState = windowStateKeeper({ 188 | defaultWidth: 900, 189 | defaultHeight: 750 190 | }) 191 | 192 | let settings = getSettings('app') 193 | 194 | if(settings['keep window state'].status) { 195 | debugLog('app', 'keeping window state changes') 196 | windowOptions.x = mainWindowState.x 197 | windowOptions.y = mainWindowState.y 198 | windowOptions.width = mainWindowState.width 199 | windowOptions.height = mainWindowState.height 200 | } 201 | 202 | if(settings['discord rpc'].status) { 203 | debugLog('app', 'discord rpc enabled') 204 | } 205 | 206 | window = new BrowserWindow(windowOptions) 207 | Menu.setApplicationMenu(mainMenu) 208 | 209 | if(getSettings('app')['keep window state'].status) { 210 | mainWindowState.manage(window) 211 | } 212 | 213 | // These now try to connect to the APIs we are using 214 | try { 215 | debugLog('api', 'creating trakt instance') 216 | global.trakt = new Trakt(traktOptions) 217 | } catch(err) { 218 | debugLog('error', 'trakt authentication', new Error().stack) 219 | } 220 | 221 | try { 222 | debugLog('api', 'creating fanart instance') 223 | global.fanart = new Fanart(process.env.fanart_key) 224 | } catch(err) { 225 | debugLog('error', 'fanart authentication', new Error().stack) 226 | } 227 | 228 | try { 229 | debugLog('api', 'creating tvdb instance') 230 | global.tvdb = new TvDB(process.env.tvdb_key) 231 | } catch(err) { 232 | debugLog('error', 'tvdb authentication', new Error().stack) 233 | } 234 | 235 | try { 236 | debugLog('api', 'creating tmdb instance') 237 | global.tmdb = new TmDB(process.env.tmdb_key) 238 | } catch(err) { 239 | debugLog('error', 'tmdb authentication', new Error().stack) 240 | } 241 | 242 | 243 | // show the window when the page is built 244 | window.once('ready-to-show', () => { 245 | debugLog('window', 'ready') 246 | window.show() 247 | }) 248 | 249 | debugLog('init time', (Date.now() - initTime)+'ms') 250 | 251 | // Now we launch the app renderer 252 | launchApp() 253 | 254 | 255 | // EVENTS 256 | 257 | // if the window gets closed, the app will quit 258 | window.on('closed', () => { 259 | debugLog('window', 'closed') 260 | win = null 261 | }) 262 | 263 | window.on('restore', () => { 264 | debugLog('window', 'restored') 265 | window.focus() 266 | }) 267 | } 268 | 269 | // here we finally build the app 270 | app.on('ready', build) 271 | 272 | // this quits the whole app 273 | app.on('window-all-closed', () => { 274 | debugLog('app', 'now closing') 275 | app.quit() 276 | }) 277 | 278 | 279 | // This launcher checks if the user is possibly logged in already. If so, we try to login with the existing credentials. If not, we go directly to the login screen. 280 | function launchApp() { 281 | if(user.trakt.auth) { 282 | debugLog('login', 'connecting existing user to trakt') 283 | tryLogin() 284 | } else { 285 | debugLog('login', 'no user found') 286 | loadLogin() 287 | } 288 | } 289 | 290 | function tryLogin() { 291 | // First, we show the loading screen to tell the user that something is happening. Eventually, when all loading processes are finished, it will be closed again to reveal the dashboard. 292 | loadLoadingScreen() 293 | 294 | // wait until loading screen is fully loaded 295 | ipcMain.once('loading-screen', (event, data) => { 296 | if(data === 'loaded') { 297 | debugLog('loading', 'can start now') 298 | 299 | global.trakt.import_token(user.trakt.auth).then(() => { 300 | global.trakt.refresh_token(user.trakt.auth).then(async newAuth => { 301 | user.trakt.auth = newAuth 302 | user.trakt.status = true 303 | saveConfig() 304 | debugLog('login', 'success') 305 | 306 | // track user stats for traktify analytics 307 | let userSettings = await trakt.users.settings().then(res => res) 308 | request(`https://traktify-server.herokuapp.com/stats?username=${userSettings.user.username}`, { 309 | json: true 310 | }, (err, res, body) => { 311 | debugLog('user authentications', body.data.requests) 312 | }) 313 | 314 | 315 | event.returnValue = 'start' 316 | 317 | // After loadingHandler is finished with everything, the dashboard is opened 318 | loadingHandler().then(() => { 319 | loadDashboard() 320 | }) 321 | }).catch(err => { 322 | if(err) { 323 | user.trakt.auth = false 324 | user.trakt.status = false 325 | saveConfig() 326 | debugLog('login failed', err) 327 | deleteCacheFolder() 328 | loadLogin() 329 | } 330 | }) 331 | }) 332 | } 333 | }) 334 | } 335 | 336 | function authenticate() { 337 | return global.trakt.get_codes().then(poll => { 338 | clipboard.writeText(poll.user_code) // give the user the code 339 | global.codeToClipboard = function codeToClipboard() { 340 | // provides the user the option to get the code again 341 | clipboard.writeText(poll.user_code) 342 | } 343 | shell.openExternal(poll.verification_url) 344 | 345 | return global.trakt.poll_access(poll) 346 | }).then(auth => { 347 | debugLog('login', 'trakt user signed in') 348 | global.trakt.import_token(auth) 349 | 350 | user.trakt.auth = auth 351 | user.trakt.status = true 352 | saveConfig() 353 | 354 | // going back to the app and heading into dashboard 355 | window.focus() 356 | tryLogin() // confirm login credentials for extra safety and start loading 357 | 358 | return true 359 | }).catch(err => { 360 | // The failing login probably won't happen because the trakt login page would already throw the error. This exist just as a fallback. 361 | if(err) { 362 | debugLog('error', 'login failed') 363 | user.trakt.auth = false 364 | user.trakt.status = false 365 | saveConfig() 366 | 367 | window.focus() 368 | loadLogin() 369 | } 370 | }) 371 | } 372 | global.authenticate = authenticate 373 | 374 | function disconnect() { 375 | global.trakt.revoke_token() 376 | user.trakt.auth = false 377 | user.trakt.status = false 378 | defaultAll('app') 379 | saveConfig() 380 | deleteCacheFolder() 381 | loadLogin() 382 | } 383 | global.disconnect = disconnect 384 | 385 | function deleteCacheFolder() { 386 | fs.exists('./.cache', ex => { 387 | if(ex) { 388 | rimraf('./.cache', () => { 389 | debugLog('cache', 'removed all files') 390 | }) 391 | } else { 392 | debugLog('cache', 'not available') 393 | } 394 | }) 395 | } 396 | 397 | // These functions do nothing but load a render page 398 | function loadLogin() { 399 | window.loadFile('pages/login/index.html') 400 | } 401 | function loadDashboard() { 402 | window.loadFile('pages/dashboard/index.html') 403 | } 404 | function loadLoadingScreen() { 405 | window.loadFile('pages/loading/index.html') 406 | } 407 | 408 | function loadingHandler() { 409 | let loadingTime = Date.now() 410 | 411 | return new Promise((resolve, reject) => { 412 | // waiting for the loading to be done 413 | ipcMain.once('loading-screen', (event, data) => { 414 | if(data === 'done') { 415 | debugLog('loading time', Date.now()-loadingTime+'ms') 416 | resolve() 417 | } 418 | }) 419 | }) 420 | } 421 | 422 | // this function can be called to save changes in the config file 423 | function saveConfig() { 424 | fs.writeFile("./config.json", JSON.stringify(global.config), err => { 425 | if(err) console.error(err) 426 | }) 427 | } 428 | 429 | // Hard reset the app, deletes user accounts. 430 | function resetTraktify(removeLogin) { 431 | let userTemp = false 432 | if(removeLogin) { 433 | disconnect() 434 | } else { 435 | userTemp = clone(user) 436 | } 437 | global.config = JSON.parse(fs.readFileSync("./def_config.json", "utf8")) 438 | if(userTemp) { 439 | global.config.user = userTemp 440 | } 441 | saveConfig() 442 | } 443 | 444 | 445 | // The getSetting and setSetting functions are used by the settings panel to get and apply custom settings. defaultAll can reset these settings by replacing the current ones with those from the default file def_config.json 446 | function getSettings(scope) { 447 | let settings = global.config.client.settings 448 | if(settings.hasOwnProperty(scope)) { 449 | return settings[scope] 450 | } else { 451 | console.error('Invalid scope at getSetting()') 452 | } 453 | } 454 | global.getSettings = getSettings 455 | 456 | function setSetting(scope, settingOption, newStatus) { 457 | let settings = global.config.client.settings[scope] 458 | let setting = settings[settingOption] 459 | 460 | if(newStatus == 'default') { 461 | setting.status = setting.default 462 | } else { 463 | switch(setting.type) { 464 | case 'select': { 465 | if(setting.options.hasOwnProperty(newStatus)) { 466 | setting.status = newStatus 467 | } 468 | break 469 | } 470 | case 'range': { 471 | if(inRange(newStatus, setting.range)) { 472 | setting.status = newStatus 473 | } 474 | break 475 | } 476 | case 'toggle': { 477 | if(typeof newStatus == 'boolean') { 478 | setting.status = newStatus 479 | } 480 | break 481 | } 482 | default: { break } 483 | } 484 | } 485 | 486 | saveConfig() 487 | } 488 | global.setSetting = setSetting 489 | 490 | function defaultAll(scope) { 491 | let settings = getSettings(scope) 492 | for(let s in settings) { 493 | setSetting(scope, s, 'default') 494 | } 495 | } 496 | global.defaultAll = defaultAll 497 | 498 | 499 | // This applies the saved settings to the master css file. The currently loaded HTML must handle the incoming message via the proper IPC helpers. 500 | function updateApp() { 501 | let settings = getSettings('app') 502 | for(let s in settings) { 503 | debugLog('updating setting', s) 504 | let setting = settings[s] 505 | // these are only the settings that can be changed in realtime 506 | switch(s) { 507 | case 'accent color': { 508 | let value = setting.options[setting.status].value 509 | window.webContents.send('modify-root', { 510 | name: '--accent_color', 511 | value: value 512 | }) 513 | 514 | let value_dark = shadeHexColor(value, -20) 515 | window.webContents.send('modify-root', { 516 | name: '--accent_color_d', 517 | value: value_dark 518 | }) 519 | 520 | break 521 | } 522 | case 'background image': { 523 | let value = setting.options[setting.status].value 524 | window.webContents.send('modify-root', { 525 | name: '--background_image', 526 | value: `url('./${value}')` 527 | }) 528 | break 529 | } 530 | case 'background opacity': { 531 | let value = setting.status 532 | window.webContents.send('modify-root', { 533 | name: '--background_opacity', 534 | value: value/100 535 | }) 536 | break 537 | } 538 | default: { break } 539 | } 540 | } 541 | } 542 | global.updateApp = updateApp 543 | 544 | // Quits the app and reopens it automatically. This is used to apply settings which would interfer with this this app.js file. 545 | function relaunchApp() { 546 | app.relaunch() 547 | app.quit(0) 548 | } 549 | global.relaunchApp = relaunchApp 550 | 551 | 552 | //:::: CACHE Listener ::::\\ 553 | const Cache = require('./modules/cache.js') 554 | const Queue = new(require('./modules/queue.js')) 555 | 556 | let keyList = {} 557 | 558 | // Instead of directly saving the cache within the request module right after changes were made, we put the saving action into a queue and also filter them to only run once each cycle. 559 | ipcMain.on('cache', (event, details) => { 560 | /** details: 561 | * name, 562 | * action, 563 | * ?data, 564 | * ?key 565 | */ 566 | switch(details.action) { 567 | case 'save': { 568 | Queue.add(function() { 569 | const cache = new Cache(details.name) 570 | cache.save() 571 | }, { overwrite: true }) 572 | break 573 | } 574 | 575 | case 'addKey': { 576 | if(!keyList.hasOwnProperty(details.name)) { 577 | // list wasn't used yet 578 | keyList[details.name] = {} 579 | } 580 | keyList[details.name][details.key] = details.data 581 | break 582 | } 583 | 584 | case 'saveKeys': { 585 | const cache = new Cache(details.name) 586 | if(!keyList.hasOwnProperty(details.name)) { 587 | // nothing was saved in the keylist 588 | debugLog('!caching', 'attempted keylist doesn\'t exist') 589 | break 590 | } 591 | for(let k in keyList[details.name]) { 592 | cache.setKey(k, keyList[details.name][k]) 593 | } 594 | cache.save() 595 | break 596 | } 597 | 598 | case 'setKey': { 599 | Queue.add(function() { 600 | const cache = new Cache(details.name) 601 | cache.setKey(details.key, details.data) 602 | }, { overwrite: true }) 603 | break 604 | } 605 | } 606 | }) 607 | 608 | 609 | //:::: LOG LISTENER ::::\\ 610 | ipcMain.on('log', (event, details) => { 611 | /** details: 612 | * action, 613 | * log 614 | */ 615 | // using the helper here to make calls from the main possible as well 616 | ipcChannels['log'](details) 617 | }) 618 | -------------------------------------------------------------------------------- /assets/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/blank.png -------------------------------------------------------------------------------- /assets/fonts/manrope-bold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/fonts/manrope-bold.woff2 -------------------------------------------------------------------------------- /assets/fonts/manrope-light.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/fonts/manrope-light.woff2 -------------------------------------------------------------------------------- /assets/fonts/manrope-medium.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/fonts/manrope-medium.woff2 -------------------------------------------------------------------------------- /assets/fonts/manrope-semibold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/fonts/manrope-semibold.woff2 -------------------------------------------------------------------------------- /assets/fonts/manrope-thin.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/fonts/manrope-thin.woff2 -------------------------------------------------------------------------------- /assets/furniture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/furniture.png -------------------------------------------------------------------------------- /assets/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/grid.png -------------------------------------------------------------------------------- /assets/grunge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/grunge.png -------------------------------------------------------------------------------- /assets/icons/app/check.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/app/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/app/heart.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /assets/icons/app/left.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/app/list.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/app/play.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /assets/icons/app/right.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/app/search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/app/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/app/signout.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/app/sync.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /assets/icons/trakt/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/trakt/128x128.png -------------------------------------------------------------------------------- /assets/icons/trakt/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/trakt/512x512.png -------------------------------------------------------------------------------- /assets/icons/trakt/trakt.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/trakt/trakt.icns -------------------------------------------------------------------------------- /assets/icons/trakt/trakt.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/trakt/trakt.ico -------------------------------------------------------------------------------- /assets/icons/trakt/trakt.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 11 | 13 | 14 | 15 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /assets/icons/trakt/trakt_no_circle.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/trakt/trakt_no_circle.png -------------------------------------------------------------------------------- /assets/icons/traktify/128x128.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/traktify/128x128.png -------------------------------------------------------------------------------- /assets/icons/traktify/512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/traktify/512x512.png -------------------------------------------------------------------------------- /assets/icons/traktify/traktify.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/traktify/traktify.icns -------------------------------------------------------------------------------- /assets/icons/traktify/traktify.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/icons/traktify/traktify.ico -------------------------------------------------------------------------------- /assets/icons/traktify/traktify.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 15 | 16 | 17 | 18 | 20 | 21 | 22 | 24 | 26 | 27 | 28 | 30 | 32 | 34 | 36 | 38 | 40 | 42 | 44 | 46 | 48 | 50 | 52 | 53 | 54 | 56 | 58 | 60 | 62 | 64 | 66 | 68 | 70 | 72 | 74 | 76 | 78 | 79 | 80 | 81 | 83 | 85 | 87 | 89 | 91 | 93 | 94 | 95 | 96 | 98 | 100 | 102 | 104 | 106 | 108 | 110 | 111 | 112 | 114 | 116 | 118 | 120 | 122 | 124 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 205 | 206 | 207 | 208 | 228 | 237 | 269 | 270 | 271 | 272 | 290 | 299 | 330 | 331 | 332 | -------------------------------------------------------------------------------- /assets/loading_placeholder.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/loading_placeholder.gif -------------------------------------------------------------------------------- /assets/loading_placeholder_nobg.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/loading_placeholder_nobg.gif -------------------------------------------------------------------------------- /assets/master.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --black: #242424; 3 | --black_d: #191919; 4 | --white: #FFF; 5 | --white_d: #CECECE; 6 | --gray: #696969; 7 | --side_dist: 30px; 8 | 9 | /* the following variables will be set by the renderer when the window loads, these settings prevent short flashing and double file loading */ 10 | --accent_color: transparent; 11 | --accent_color_d: transparent; 12 | --background_image: none; 13 | --background_opacity : 1; 14 | } 15 | 16 | * { 17 | user-select: none; 18 | } 19 | 20 | *:focus { 21 | outline: none; 22 | } 23 | /*::::::::::::::::::::::::::::::::::::::::::::::: FONTS :::::::::::::::::::::::::::::::::::::::::::::::*/ 24 | @font-face { 25 | font-family: 'manrope'; 26 | font-weight: 200; 27 | src: url('./fonts/manrope-thin.woff2'); 28 | } 29 | @font-face { 30 | font-family: 'manrope'; 31 | font-weight: 300; 32 | src: url('./fonts/manrope-light.woff2'); 33 | } 34 | @font-face { 35 | font-family: 'manrope'; 36 | font-weight: 500; 37 | src: url('./fonts/manrope-medium.woff2'); 38 | } 39 | @font-face { 40 | font-family: 'manrope'; 41 | font-weight: 600; 42 | src: url('./fonts/manrope-semibold.woff2'); 43 | } 44 | @font-face { 45 | font-family: 'manrope'; 46 | font-weight: 700; 47 | src: url('./fonts/manrope-bold.woff2'); 48 | } 49 | /*::::::::::::::::::::::::::::::::::::::::::::::: TYPOGRAPHY :::::::::::::::::::::::::::::::::::::::::::::::*/ 50 | .h1 { 51 | font: 600 32px manrope; 52 | } 53 | .h2 { 54 | font: 600 27px manrope; 55 | } 56 | .h3 { 57 | font: 500 23px manrope; 58 | } 59 | .p { 60 | font: 200 18px manrope; 61 | } 62 | 63 | .fw200 { 64 | font-weight: 200; 65 | } 66 | .fw500 { 67 | font-weight: 500; 68 | } 69 | .fw600 { 70 | font-weight: 600; 71 | } 72 | .fw700 { 73 | font-weight: 700; 74 | } 75 | 76 | .fs23 { 77 | font-size: 23px; 78 | } 79 | .fs18 { 80 | font-size: 18px; 81 | } 82 | .fs16 { 83 | font-size: 16px; 84 | } 85 | .fs14 { 86 | font-size: 14.5px; 87 | line-height: 20.5px; 88 | } 89 | .fs12 { 90 | font-size: 12px; 91 | } 92 | 93 | .tu { 94 | text-transform: uppercase; 95 | } 96 | .t_ { 97 | text-decoration: underline; 98 | } 99 | .tOverflow { 100 | overflow: hidden; 101 | text-overflow: ellipsis; 102 | white-space: nowrap; 103 | } 104 | 105 | .tOverflow.normal { 106 | white-space: normal; 107 | } 108 | /*::::::::::::::::::::::::::::::::::::::::::::::: POSITIONING :::::::::::::::::::::::::::::::::::::::::::::::*/ 109 | .top, 110 | .top_p, 111 | .bottom, 112 | .bottom_p, 113 | .right, 114 | .left { 115 | position: fixed; 116 | } 117 | 118 | .center { 119 | position: relative; 120 | height: 100%; 121 | } 122 | 123 | .center * { 124 | overflow: auto; 125 | margin: auto; 126 | position: absolute; 127 | top: 0; 128 | left: 0; 129 | bottom: 0; 130 | right: 0; 131 | /* set height property to the individual element */ 132 | } 133 | 134 | .top { 135 | top: 0; 136 | } 137 | .bottom { 138 | bottom: 0; 139 | } 140 | .right { 141 | right: var(--side_dist); 142 | } 143 | .left { 144 | left: var(--side_dist); 145 | } 146 | 147 | .top_p { 148 | top: var(--side_dist); 149 | } 150 | .bottom_p { 151 | bottom: var(--side_dist); 152 | } 153 | 154 | .z1 { 155 | z-index: 1; 156 | } 157 | .z2 { 158 | z-index: 2; 159 | } 160 | .z3 { 161 | z-index: 3; 162 | } 163 | .z4 { 164 | z-index: 4; 165 | } 166 | /*::::::::::::::::::::::::::::::::::::::::::::::: COLORS :::::::::::::::::::::::::::::::::::::::::::::::*/ 167 | .black_t { 168 | color: var(--black); 169 | } 170 | .black_d_t { 171 | color: var(--black_d); 172 | } 173 | .white_t { 174 | color: var(--white); 175 | } 176 | .white_d_t { 177 | color: var(--white_d); 178 | } 179 | .red_t { 180 | color: var(--accent_color); 181 | } 182 | .red_d_t { 183 | color: var(--accent_color_d); 184 | } 185 | 186 | .black_b { 187 | background-color: var(--black); 188 | } 189 | .black_d_b { 190 | background-color: var(--black_d); 191 | } 192 | .white_b { 193 | background-color: var(--white); 194 | } 195 | .white_d_b { 196 | background-color: var(--white_d); 197 | } 198 | .red_b { 199 | background-color: var(--accent_color); 200 | } 201 | .red_d_b { 202 | background-color: var(--accent_color_d); 203 | } 204 | /*::::::::::::::::::::::::::::::::::::::::::::::: ATTRIBUTES :::::::::::::::::::::::::::::::::::::::::::::::*/ 205 | .shadow_h { 206 | box-shadow: 0px 0px 34px rgba(0, 0, 0, 0.3); 207 | } 208 | .shadow_b { 209 | box-shadow: 0px 0px 12px rgba(0, 0, 0, 0.5) 210 | } 211 | 212 | .vertical_border { 213 | background-color: var(--white); 214 | opacity: 0.06; 215 | width: 1px; 216 | height: 100%; 217 | } 218 | /*::::::::::::::::::::::::::::::::::::::::::::::: COMPONENTS :::::::::::::::::::::::::::::::::::::::::::::::*/ 219 | body { 220 | margin: 0; 221 | font-family: manrope!important; 222 | overflow: hidden; 223 | background: var(--black_d); 224 | user-select: none; 225 | } 226 | 227 | body::after { 228 | content: ''; 229 | width: 100%; 230 | height: 100%; 231 | position: absolute; 232 | top: 0; 233 | left: 0; 234 | z-index: -1; 235 | background: var(--background_image) repeat; 236 | opacity: var(--background_opacity); 237 | } 238 | 239 | #dragger { 240 | position: absolute; 241 | z-index: 10; 242 | width: 100vw; 243 | height: 20px; 244 | -webkit-app-region: drag; 245 | } 246 | 247 | .wrapper { 248 | position: relative; 249 | } 250 | .wrapper.flex { 251 | display: flex; 252 | min-height: calc(100vh - 60px); 253 | min-width: calc(100vw - 60px); 254 | margin: auto; 255 | } 256 | /*::::::::::::::::::::::: LOADING :::::::::::::::::::::::*/ 257 | 258 | .loading-animation { 259 | background: linear-gradient(to right, var(--accent_color), var(--accent_color_d), var(--accent_color)); 260 | background-size: 600% 600%; 261 | animation: gradient-animation 3s ease infinite; 262 | } 263 | 264 | @keyframes gradient-animation { 265 | 0%{background-position:100% 0%} 266 | 75%{background-position:0% 50%} 267 | 100%{backround-position:100% 0%} 268 | } 269 | 270 | .gray-animation { 271 | animation: gray-animation 2s ease infinite; 272 | } 273 | 274 | @keyframes gray-animation { 275 | 0%{filter:grayscale(0%)} 276 | 75%{filter:grayscale(50%)} 277 | 100%{filter:grayscale(0%)} 278 | } 279 | /*::::::::::::::::::::::: SCROLLERS :::::::::::::::::::::::*/ 280 | ::-webkit-scrollbar { 281 | background-color: transparent; 282 | width: 13px; 283 | } 284 | ::-webkit-scrollbar-thumb { 285 | background-color: var(--black_d); 286 | background-clip: padding-box; 287 | border: solid transparent 3px; 288 | border-radius: 30px; 289 | } 290 | 291 | .slider { 292 | width: 100%; 293 | height: 5px; 294 | border-radius: 5px; 295 | outline: none; 296 | -webkit-appearance: none; 297 | } 298 | .slider::-webkit-slider-thumb { 299 | appearance: none; 300 | width: 20px; 301 | height: 20px; 302 | background: var(--accent_color_d); 303 | border-radius: 50%; 304 | -webkit-appearance: none; 305 | cursor: pointer; 306 | } 307 | /*::::::::::::::::::::::: BUTTONS :::::::::::::::::::::::*/ 308 | .btns { 309 | list-style: none; 310 | padding: 0; 311 | margin: 0; 312 | } 313 | .btns .btn { 314 | margin-bottom: 8px; 315 | } 316 | 317 | .btn { 318 | padding: 10px 30px; 319 | border-radius: 20px; 320 | cursor: pointer; 321 | transition: all 200ms ease; 322 | display: block; 323 | } 324 | .btn:hover { 325 | filter: grayscale(20%); 326 | } 327 | .btn.selected { 328 | background-color: var(--accent_color); 329 | } 330 | .btn.rotating { 331 | animation-name: rotate; 332 | animation-duration: 0.8s; 333 | animation-iteration-count: infinite; 334 | } 335 | @keyframes rotate { 336 | from {transform: rotate(0deg);} 337 | to {transform: rotate(360deg);} 338 | } 339 | .btn.bottom { 340 | padding: 10px 19px; 341 | border-radius: 20px 20px 0 0; 342 | } 343 | 344 | .btn.icon { 345 | border-radius: 50%; 346 | width: min-content; 347 | padding: 12px; 348 | display: flex; 349 | } 350 | .btn.icon_small { 351 | border-radius: 50%; 352 | padding: 4px; 353 | display: flex; 354 | height: min-content; 355 | width: min-content; 356 | } 357 | 358 | .btn.icon img { 359 | filter: invert(1); 360 | width: 20px; 361 | height: 20px; 362 | vertical-align: middle; 363 | } 364 | .btn.icon_small img { 365 | filter: invert(1); 366 | width: 12px; 367 | height: 12px; 368 | vertical-align: middle; 369 | } 370 | 371 | .btn-switch { 372 | font-size: 10px; 373 | line-height: 14.5px; 374 | position: relative; 375 | display: inline-block; 376 | } 377 | .btn-switch__radio { 378 | display: none; 379 | } 380 | .btn-switch__label { 381 | display: inline-block; 382 | padding: 0.6em 0.75em 1em 0.5em; 383 | cursor: pointer; 384 | transition: color 200ms ease; 385 | } 386 | .btn-switch__txt { 387 | position: relative; 388 | z-index: 2; 389 | display: inline-block; 390 | min-width: 18px; 391 | opacity: 1; 392 | pointer-events: none; 393 | transition: opacity 200ms ease; 394 | } 395 | .btn-switch__label:before { 396 | content: ""; 397 | position: absolute; 398 | z-index: -1; 399 | top: 0; 400 | right: 0; 401 | bottom: 0; 402 | left: 0; 403 | background: var(--accent_color); 404 | border-radius: 1.5em; 405 | box-shadow: inset 0 0.0715em 0.3572em rgba(43, 43, 43, 0.05); 406 | transition: background 200ms ease; 407 | } 408 | .btn-switch__radio_no:checked ~ .btn-switch__label:before { 409 | background: var(--gray); 410 | } 411 | .btn-switch__label_no:after { 412 | content: ""; 413 | position: absolute; 414 | z-index: 2; 415 | top: 0.5em; 416 | bottom: 0.5em; 417 | left: 0.5em; 418 | width: 2em; 419 | background: var(--white); 420 | border-radius: 1em; 421 | pointer-events: none; 422 | box-shadow: 0 0.1429em 0.2143em rgba(43, 43, 43, 0.2), 423 | 0 0.3572em 0.3572em rgba(43, 43, 43, 0.1); 424 | transition: left 200ms ease, background 200ms ease; 425 | } 426 | .btn-switch__radio_yes:checked ~ .btn-switch__label_no:after { 427 | left: calc(100% - 2.5em); 428 | background: var(--white); 429 | } 430 | .btn-switch__radio_no:checked ~ .btn-switch__label_yes:before, 431 | .btn-switch__radio_yes:checked ~ .btn-switch__label_no:before { 432 | z-index: 1; 433 | } 434 | .btn-switch__radio_no:checked ~ .btn-switch__label_yes { 435 | color: var(--white); 436 | } 437 | /*::::::::::::::::::::::: OVERLAY :::::::::::::::::::::::*/ 438 | .overlay { 439 | left: 0; 440 | height: 100vh; 441 | width: 100vw; 442 | display: none; 443 | 444 | overflow: hidden; 445 | background-color: var(--black_d); 446 | 447 | opacity: 0.75; 448 | animation: blend_out 150ms cubic-bezier(.165,.84,.44,1) 1 forwards; 449 | } 450 | .overlay.show { 451 | display: block!important; 452 | animation: blend_in 150ms cubic-bezier(.165,.84,.44,1) 1 forwards!important; 453 | } 454 | /*::::::::::::::::::::::: POSTERS :::::::::::::::::::::::*/ 455 | .posters { 456 | list-style: none; 457 | padding: 0; 458 | margin: 0; 459 | } 460 | 461 | .poster { 462 | position: relative; 463 | width: 140px; 464 | height: 205px; 465 | margin-right: 20px; 466 | display: inline-block; 467 | transition: all 400ms ease; 468 | } 469 | 470 | .poster > img { 471 | object-fit: cover; 472 | width: 100%; 473 | height: 100%; 474 | animation: fade_in 0.1s; 475 | } 476 | @keyframes fade_in { 477 | 0% { 478 | opacity: 0; 479 | } 480 | 100% { 481 | opacity: 1; 482 | } 483 | } 484 | 485 | .poster, 486 | .poster img { 487 | border-radius: 5px; 488 | } 489 | /*:::::::::::::::::::::::::::::::::::::::::::::::: ACTION-BUTTTONS :::::::::::::::::::::::::::::::::::::::::::::::*/ 490 | .beta_action_btns { 491 | margin: 30px 0; 492 | width: 100%; 493 | } 494 | 495 | .beta_action_btn { 496 | text-align: center; 497 | padding: 5px 25px; 498 | border-radius: 4px; 499 | margin: 15px 0; 500 | color: var(--white); 501 | cursor: pointer; 502 | transition: opacity 200ms ease; 503 | } 504 | 505 | .beta_action_btn:hover { 506 | opacity: .8; 507 | } 508 | 509 | .beta_action_btn * { 510 | vertical-align: middle; 511 | display: inline-block; 512 | } 513 | 514 | .beta_action_btn > img { 515 | width: 16px; 516 | height: 19px; 517 | margin-right: 10px; 518 | } 519 | 520 | .beta_action_btn.play { 521 | background-color: #ED1C24; 522 | } 523 | .beta_action_btn.play::after { 524 | content: 'Check in?' 525 | } 526 | 527 | .beta_action_btn.watchlist { 528 | background-color: #3796D5; 529 | } 530 | .beta_action_btn.watchlist::after { 531 | content: 'Watchlist' 532 | } 533 | 534 | .beta_action_btn.watched { 535 | background-color: #BD57E8; 536 | } 537 | .beta_action_btn.watched::after { 538 | content: 'Watched?' 539 | } 540 | /*::::::::::::::::::::::::::::::::::::::::::::::: ANIMATIONS :::::::::::::::::::::::::::::::::::::::::::::::*/ 541 | .animation_slide_up { 542 | animation: slide_up 500ms cubic-bezier(.165,.84,.44,1) 1 forwards; 543 | } 544 | @keyframes slide_up { 545 | 0% {opacity:0;transform:translateY(50px)} 546 | 50% {opacity:0;transform:translateY(25px)} 547 | 100% {opacity:1;transform:translateY(0)} 548 | } 549 | 550 | .animation_slide_right { 551 | animation: slide_right 500ms cubic-bezier(.165,.84,.44,1) 1 forwards; 552 | } 553 | @keyframes slide_right { 554 | 0% {opacity:0;transform:translateX(30px)} 555 | 50% {opacity:0;transform:translateX(15px)} 556 | 100% {opacity:1;transform:translateX(0)} 557 | } 558 | 559 | .animation_blend_in { 560 | animation: blend_in 150ms cubic-bezier(.165,.84,.44,1) 1 forwards; 561 | } 562 | @keyframes blend_in { 563 | 0% {opacity:0;backdrop-filter:blur(0px)} 564 | 50% {opacity:0;backdrop-filter:blur(4px)} 565 | 100% {opacity:0.75;backdrop-filter:blur(8px)} 566 | } 567 | 568 | .animation_blend_out { 569 | animation: blend_out 150ms cubic-bezier(.165,.84,.44,1) 1 forwards; 570 | } 571 | @keyframes blend_out { 572 | 0% {opacity: 0.75;backdrop-filter: blur(8px)} 573 | 50% {opacity:0;backdrop-filter:blur(4px)} 574 | 100% {opacity:0;backdrop-filter: blur(0px)} 575 | } 576 | 577 | .animation_fade_out { 578 | animation: fade_out 1.2s cubic-bezier(.165,.84,.44,1) 1 forwards; 579 | } 580 | @keyframes fade_out { 581 | 0% {opacity:1;transform:translateY(0)} 582 | 50% {opacity:0;transform:translateY(50px)} 583 | 100% {opacity:0;transform:translateY(100px)} 584 | } 585 | /*::::::::::::::::::::::::::::::::::::::::::::::: RESPONSIVE :::::::::::::::::::::::::::::::::::::::::::::::*/ 586 | @media screen and (max-width: 952px) { 587 | :root { 588 | --side_dist: 20px; 589 | } 590 | 591 | .h1 { 592 | font-size: 27px; 593 | } 594 | .h2 { 595 | font-size: 23px; 596 | } 597 | .h3 { 598 | font-size: 18px; 599 | } 600 | .p { 601 | font-size: 16px; 602 | } 603 | 604 | .wrapper.flex { 605 | min-height: calc(100vh - 40px); 606 | min-width: calc(100vw - 40px); 607 | } 608 | } 609 | -------------------------------------------------------------------------------- /assets/placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/placeholder.png -------------------------------------------------------------------------------- /assets/placeholder_nobg.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/placeholder_nobg.png -------------------------------------------------------------------------------- /assets/previews/blank.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/previews/blank.png -------------------------------------------------------------------------------- /assets/previews/furniture.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/previews/furniture.png -------------------------------------------------------------------------------- /assets/previews/grid.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/previews/grid.png -------------------------------------------------------------------------------- /assets/previews/grunge.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/assets/previews/grunge.png -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "client": { 3 | "settings": { 4 | "app": { 5 | "accent color": { 6 | "type": "select", 7 | "status": "red", 8 | "default": "red", 9 | "options": { 10 | "red": { 11 | "value": "#ED1C24" 12 | }, 13 | "brown": { 14 | "value": "#A88E79" 15 | }, 16 | "yellow": { 17 | "value": "#FF980D" 18 | }, 19 | "green": { 20 | "value": "#0FD67C" 21 | }, 22 | "violet": { 23 | "value": "#610BEA" 24 | }, 25 | "blue": { 26 | "value": "#04BA9C" 27 | } 28 | }, 29 | "needsReload": false 30 | }, 31 | "background image": { 32 | "type": "select", 33 | "status": "furniture", 34 | "default": "furniture", 35 | "options": { 36 | "furniture": { 37 | "value": "furniture.png", 38 | "preview": true 39 | }, 40 | "grid": { 41 | "value": "grid.png", 42 | "preview": true 43 | }, 44 | "grunge": { 45 | "value": "grunge.png", 46 | "preview": true 47 | }, 48 | "blank": { 49 | "value": "blank.png", 50 | "preview": true 51 | } 52 | }, 53 | "needsReload": false 54 | }, 55 | "background opacity": { 56 | "type": "range", 57 | "status": 100, 58 | "default": 100, 59 | "accuracy": 5, 60 | "range": [ 61 | 0, 62 | 100 63 | ], 64 | "needsReload": false 65 | }, 66 | "keep window state": { 67 | "type": "toggle", 68 | "status": false, 69 | "default": false, 70 | "needsReload": true 71 | }, 72 | "discord rpc": { 73 | "type": "toggle", 74 | "status": false, 75 | "default": false, 76 | "needsReload": true 77 | } 78 | } 79 | }, 80 | "cache": { 81 | "path": "../.cache/", 82 | "expires": 0 83 | }, 84 | "placeholder": { 85 | "poster": "placeholder.png", 86 | "search": "placeholder_nobg.png" 87 | }, 88 | "rpc": { 89 | "states": [ 90 | "Frankly, my dear, I don't give a damn.", 91 | "Here's looking at you, kid.", 92 | "You're gonna need a bigger boat.", 93 | "May the Force be with you.", 94 | "Toto, I've a feeling we're not in Kansas anymore.", 95 | "I'm going to make him an offer he can't refuse.", 96 | "You talkin' to me?", 97 | "There's no place like home.", 98 | "I am your father.", 99 | "Why so serious?", 100 | "I'll have what she's having.", 101 | "We'll always have Paris.", 102 | "Bond. James Bond.", 103 | "I see dead people.", 104 | "I'll be back.", 105 | "You can't handle the truth!", 106 | "E.T. phone home.", 107 | "Yippie-ki-yay, motherf—er!", 108 | "To infinity and beyond!", 109 | "Houston, we have a problem.", 110 | "You had me at hello.", 111 | "There's no crying in baseball!", 112 | "Here's Johnny!", 113 | "I am serious. And don't call me Shirley.", 114 | "Carpe diem. Seize the day, boys.", 115 | "Leave the gun. Take the cannoli.", 116 | "Show me the money!", 117 | "Say hello to my little friend!", 118 | "Shaken, not stirred.", 119 | "I'm the king of the world!", 120 | "Mama says, 'Stupid is as stupid does.'", 121 | "Just keep swimming.", 122 | "If you build it, he will come.", 123 | "I'm not bad. I'm just drawn that way.", 124 | "I'm having an old friend for dinner.", 125 | "Hasta la vista, baby.", 126 | "The Dude abides.", 127 | "Stella! Hey, Stella!", 128 | "After all, tomorrow is another day!", 129 | "Help me, Obi-Wan Kenobi.", 130 | "Go ahead, make my day.", 131 | "It's alive! It's alive!", 132 | "Argo f— yourself.", 133 | "My precious.", 134 | "Good morning, Vietnam!", 135 | "I wish I knew how to quit you.", 136 | "That'll do, pig. That'll do.", 137 | "Elementary, my dear Watson.", 138 | "You ain't heard nothin' yet!", 139 | "Wax on, wax off.", 140 | "Yo, Adrian!", 141 | "Nobody's perfect.", 142 | "They call it a Royale with cheese.", 143 | "It was Beauty killed the Beast.", 144 | "I bought the airline. It seemed neater!" 145 | ] 146 | } 147 | }, 148 | "user": { 149 | "trakt": { 150 | "auth": false, 151 | "status": false 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /def_config.json: -------------------------------------------------------------------------------- 1 | { 2 | "client": { 3 | "settings": { 4 | "app": { 5 | "accent color": { 6 | "type": "select", 7 | "status": "red", 8 | "default": "red", 9 | "options": { 10 | "red": { 11 | "value": "#ED1C24" 12 | }, 13 | "brown": { 14 | "value": "#A88E79" 15 | }, 16 | "yellow": { 17 | "value": "#FF980D" 18 | }, 19 | "green": { 20 | "value": "#0FD67C" 21 | }, 22 | "violet": { 23 | "value": "#610BEA" 24 | }, 25 | "blue": { 26 | "value": "#04BA9C" 27 | } 28 | }, 29 | "needsReload": false 30 | }, 31 | "background image": { 32 | "type": "select", 33 | "status": "furniture", 34 | "default": "furniture", 35 | "options": { 36 | "furniture": { 37 | "value": "furniture.png", 38 | "preview": true 39 | }, 40 | "grid": { 41 | "value": "grid.png", 42 | "preview": true 43 | }, 44 | "grunge": { 45 | "value": "grunge.png", 46 | "preview": true 47 | }, 48 | "blank": { 49 | "value": "blank.png", 50 | "preview": true 51 | } 52 | }, 53 | "needsReload": false 54 | }, 55 | "background opacity": { 56 | "type": "range", 57 | "status": 100, 58 | "default": 100, 59 | "accuracy": 5, 60 | "range": [ 61 | 0, 62 | 100 63 | ], 64 | "needsReload": false 65 | }, 66 | "keep window state": { 67 | "type": "toggle", 68 | "status": false, 69 | "default": false, 70 | "needsReload": true 71 | }, 72 | "discord rpc": { 73 | "type": "toggle", 74 | "status": false, 75 | "default": false, 76 | "needsReload": true 77 | } 78 | } 79 | }, 80 | "cache": { 81 | "path": "../.cache/", 82 | "expires": 0 83 | }, 84 | "placeholder": { 85 | "poster": "placeholder.png", 86 | "search": "placeholder_nobg.png" 87 | }, 88 | "rpc": { 89 | "states": [ 90 | "Frankly, my dear, I don't give a damn.", 91 | "Here's looking at you, kid.", 92 | "You're gonna need a bigger boat.", 93 | "May the Force be with you.", 94 | "Toto, I've a feeling we're not in Kansas anymore.", 95 | "I'm going to make him an offer he can't refuse.", 96 | "You talkin' to me?", 97 | "There's no place like home.", 98 | "I am your father.", 99 | "Why so serious?", 100 | "I'll have what she's having.", 101 | "We'll always have Paris.", 102 | "Bond. James Bond.", 103 | "I see dead people.", 104 | "I'll be back.", 105 | "You can't handle the truth!", 106 | "E.T. phone home.", 107 | "Yippie-ki-yay, motherf—er!", 108 | "To infinity and beyond!", 109 | "Houston, we have a problem.", 110 | "You had me at hello.", 111 | "There's no crying in baseball!", 112 | "Here's Johnny!", 113 | "I am serious. And don't call me Shirley.", 114 | "Carpe diem. Seize the day, boys.", 115 | "Leave the gun. Take the cannoli.", 116 | "Show me the money!", 117 | "Say hello to my little friend!", 118 | "Shaken, not stirred.", 119 | "I'm the king of the world!", 120 | "Mama says, 'Stupid is as stupid does.'", 121 | "Just keep swimming.", 122 | "If you build it, he will come.", 123 | "I'm not bad. I'm just drawn that way.", 124 | "I'm having an old friend for dinner.", 125 | "Hasta la vista, baby.", 126 | "The Dude abides.", 127 | "Stella! Hey, Stella!", 128 | "After all, tomorrow is another day!", 129 | "Help me, Obi-Wan Kenobi.", 130 | "Go ahead, make my day.", 131 | "It's alive! It's alive!", 132 | "Argo f— yourself.", 133 | "My precious.", 134 | "Good morning, Vietnam!", 135 | "I wish I knew how to quit you.", 136 | "That'll do, pig. That'll do.", 137 | "Elementary, my dear Watson.", 138 | "You ain't heard nothin' yet!", 139 | "Wax on, wax off.", 140 | "Yo, Adrian!", 141 | "Nobody's perfect.", 142 | "They call it a Royale with cheese.", 143 | "It was Beauty killed the Beast.", 144 | "I bought the airline. It seemed neater!" 145 | ] 146 | } 147 | }, 148 | "user": { 149 | "trakt": { 150 | "auth": false, 151 | "status": false 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /docs/cache.md: -------------------------------------------------------------------------------- 1 | # Cache 2 | 3 | ## Workflow 4 | The standard procedure of getting API data. Before starting time intensive API requests, we check if the cache already holds what we want. 5 | 6 | ```js 7 | const cacheName = new Cache('cacheName') 8 | 9 | function getApiData() { 10 | let cacheContent = cacheName.getKey('apiData') 11 | 12 | if(cacheContent === undefined) { 13 | return requestApiData().then(apiData => { 14 | debugLog('caching', 'apiData') 15 | let cachingTime = Date.now() 16 | 17 | cacheName.setKey('apiData', apiData) 18 | cacheName.save() 19 | 20 | debugLog('caching time', Date.now()-cachingTime) 21 | return apiData 22 | }) 23 | } else { 24 | debugLog('cache available', 'apiData') 25 | return cacheContent 26 | } 27 | } 28 | ``` 29 | -------------------------------------------------------------------------------- /docs/config.md: -------------------------------------------------------------------------------- 1 | # Configuration 2 | The configuration file has the following structure: 3 | ```cson 4 | config: 5 | client: 6 | settings: 7 | app 8 | user 9 | user: 10 | trakt: 11 | auth 12 | status 13 | ``` 14 | 15 | ## Settings 16 | There are two different scopes available: `app` and `user`. 17 | 18 | ### App 19 | The `app` scope contains settings of different types. All types share these properties: 20 | 21 | property | info 22 | ---|--- 23 | type | Type of the setting 24 | status | The currently set value 25 | default | The default setting 26 | needsReload | Whether the app has to reload to apply changes 27 | 28 | The `select` type allows to choose one option from a list. Each option hast a name and a value. 29 | ```cson 30 | setting: 31 | type: 'select' 32 | status: name 33 | default: name 34 | options: { 35 | name: any 36 | name: any 37 | ... 38 | } 39 | needsReload: boolean 40 | ``` 41 | 42 | The `toggle` type can switch between `true` and `false`. 43 | ```cson 44 | setting: 45 | type: 'toggle' 46 | status: boolean 47 | default: boolean 48 | needsReload: boolean 49 | ``` 50 | 51 | The `range` type has a minimum and maximum possible value. The accuracy is defined by the `accuracy` property. 52 | ```cson 53 | setting: 54 | type: 'range' 55 | status: number 56 | default: number 57 | accuracy: number 58 | range: [number, number] 59 | needsReload: boolean 60 | ``` 61 | 62 | ### User 63 | This scope is not in use yet. 64 | -------------------------------------------------------------------------------- /docs/episodes.md: -------------------------------------------------------------------------------- 1 | # Episodes 2 | 3 | ## Request Structure 4 | Fetching a list of all episodes a show contains requires some workflow as it can't be done in one single step. This first seems like a problem but later helps to improve the requesting-caching-system. 5 | 6 | In the following, the request tree is shown. You can see that retrieving extended data is possible when requesting a list of seasons but not for a list of episodes, where a separate request for each episode is required. 7 | 8 | ``` 9 | shows/${show_id}/seasons -> season[] 10 | season: { ?extended=full 11 | number, 12 | ids: { trakt, tvdb, tmdb }, 13 | ... 14 | } 15 | 16 | shows/${show_id}/seasons/${season.number} -> episode[] 17 | episode: { 18 | season, 19 | number, 20 | title, 21 | ids: { trakt, tvdb, tmdb } 22 | } 23 | 24 | shows/${show_id}/seasons/${season.number}/episodes/${episode.number} -> episodeData 25 | episodeData: { ?extended=full 26 | ... 27 | } 28 | ``` 29 | 30 | Lets say, this is the structure of a show that I just came up with: 31 | 32 | ``` 33 | show: 34 | season 1: 35 | episode 1 36 | episode 2 37 | season 2: 38 | episode 3 39 | episode 5 40 | episode 6 41 | ``` 42 | 43 | Then you would need 9 total requests to get all available data of this exact data structure—one for the list of seasons, two for the lists of episodes of each of its seasons and finally six for each of the episodes these lists contain. 44 | 45 | Unfortunately it is not possible to predict how many requests will be required in total as the general show info would only provide the amount of episodes and not the amount of seasons. However, with the first request that provides a list of seasons, you can calculate just that. In the following pseudo-code, `show` is equivalent to the result `season[]` of the first request shown in the block above. Thus, `totalRequests` does not include this required first request into the calculation. 46 | 47 | ``` 48 | seasons = show.length 49 | episodes = 0 50 | 51 | for season in show: 52 | episodes += season.episode_count 53 | 54 | totalRequests = seasons + episodes 55 | ``` 56 | 57 | ## Card Slider 58 | The info-card feature of traktify is the most obvious usecase of the above described workflow. When the user clicks on a recommended episode that is shown in the up-next-dashboard, a slider will show up that allows the user to look at all episodes the corresponding show contains. This slider will open up with an initial position of the episode seen before on the dashboard. 59 | 60 | Because many shows contain hundrets if not thousands of episodes, it will be impractical to request all of them in one go. To reduce API stress and loading time dramatically, only a few episodes need to be requested when opening the slider. 61 | 62 | For better illustration, I will represent the list of episodes a show contains with a line of dashes where every character stands for one episode: 63 | 64 | - `-` means unrequested 65 | - `x` means requested 66 | - `o` means requested and currently shown 67 | 68 | In the following example, the 11th item of a show containing 17 total episodes is opened. The slider will be opened in the second you clicked on it but the content is shown later when the requests finished. To improve user experience, the data of the clicked episode will be requested first. 69 | 70 | When clicked: 71 | ``` 72 | ----------o------ 73 | ``` 74 | After buffer requests finished: 75 | ``` 76 | --------xxoxx---- 77 | ``` 78 | 79 | When moving one episode to the right, the required data is already requested in the background and can be shown immediately. After this, more of the hidden episodes can be requested to keep a buffer of two around the currently visible one. 80 | 81 | When moved one to the right: 82 | ``` 83 | --------xxxox---- 84 | ``` 85 | After buffer requests finished: 86 | ``` 87 | --------xxxoxx--- 88 | ``` 89 | 90 | A similar procedure can be used to show the items inside a watchlist or when clicking on a result in the search-panel. 91 | -------------------------------------------------------------------------------- /docs/helpers.md: -------------------------------------------------------------------------------- 1 | # Helper Methods 2 | 3 | ## Debugging 4 | For debugging purposes in development mode, the following methods are provided. 5 | 6 | ### Logging messages and errors 7 | The function `debugLog` provides automatic error recognition. To log a normal message you use it like this: 8 | 9 | ```js 10 | debugLog('message title', 'description' [, 'optional message']) 11 | ``` 12 | 13 | To log it as an error, for example inside a `.catch(err => ...)` you have to put either `'err'` or `'error'` as the message title. The `'name'` argument lets you give the error a name, so you can find it in your code more easily. The optional argument must be a new `Error` instance. That way you get the location of your debug logging. 14 | 15 | ```js 16 | ... 17 | .catch(err => { 18 | debugLog('error', 'name' [, new Error().stack]) 19 | }) 20 | ``` 21 | -------------------------------------------------------------------------------- /modules/api/cachers.js: -------------------------------------------------------------------------------- 1 | const { 2 | debugLog 3 | } = require('./../helper.js') 4 | 5 | 6 | module.exports = { 7 | cacheRequest, 8 | cacheSave, 9 | flushCache 10 | } 11 | 12 | const Cache = require('./../cache.js') 13 | 14 | 15 | /** 16 | * Returns the data saved in the cache as the given key and requests it if nothing was saved. The data will be temporarily stored to make processing request arrays easier. To permanently save the results to the cache, use cacheSave(). 17 | * @param {String} cacheName Name of the cache data will be saved to 18 | * @param {String} cacheKey Key under which the data is acessed 19 | * @param {Promise} request API request which gets the data in case it wasn't cached before 20 | * @param {Boolean} saveRightAfter Save the data to cache after requesting it 21 | */ 22 | function cacheRequest(cacheName, cacheKey, request, saveRightAfter) { 23 | debugLog('cache', `requesting ${cacheKey} from ${cacheName}`) 24 | let cache = new Cache(cacheName) 25 | let cacheContent = cache.getKey(cacheKey) 26 | 27 | if(cacheContent === undefined) { 28 | return request().then(result => { 29 | debugLog('cache', `caching ${cacheKey}`) 30 | 31 | ipcRenderer.send('cache', { 32 | action: 'addKey', 33 | name: cacheName, 34 | key: cacheKey, 35 | data: result 36 | }) 37 | 38 | if(saveRightAfter) { 39 | cacheSave(cacheName) 40 | } 41 | 42 | return result 43 | }) 44 | } else { 45 | // In this case, everything that was cached is uptodate 46 | debugLog('cache', `restoring ${cacheKey}`) 47 | // Returning a resolved Promise, so it will have the same type as the case above. That way it can be used with a .then() later in the caller without needing to know if the result comes from cache or an API request. 48 | return Promise.resolve(cacheContent) 49 | } 50 | } 51 | 52 | function cacheSave(cacheName) { 53 | ipcRenderer.send('cache', { 54 | action: 'saveKeys', 55 | name: cacheName 56 | }) 57 | } 58 | 59 | function flushCache(cacheName, cacheId) { 60 | let cache = new Cache(cacheName) 61 | cache.removeKey(cacheId) 62 | cache.save() 63 | } 64 | -------------------------------------------------------------------------------- /modules/api/getters.js: -------------------------------------------------------------------------------- 1 | const { 2 | debugLog 3 | } = require('./../helper.js') 4 | 5 | 6 | module.exports = { 7 | getUnfinishedProgressList, 8 | getSeasonList, 9 | getEpisodeData, 10 | getShowImages, 11 | getShowList 12 | } 13 | 14 | const { 15 | requestShowList, 16 | requestShowProgress, 17 | requestSeasonList, 18 | requestEpisodeData, 19 | requestShowImages 20 | } = require('./requesters.js') 21 | 22 | const { 23 | cacheSave, 24 | cacheRequest, 25 | flushCache 26 | } = require('./cachers.js') 27 | 28 | 29 | /** 30 | * Gets an array of n shows and it's progress that are not finished yet. If argument update is true, the updated items are re-requested instead of directly restored from cache. 31 | * @param {Number} n Number of sequential shows with unseen episodes to get 32 | * @param {Boolean} update If cached data should be checked against possible updates 33 | */ 34 | function getUnfinishedProgressList(n, update) { 35 | return new Promise(async (resolve, rej) => { 36 | let visible 37 | 38 | // holds ids of shows that require re-requests 39 | let updatedIDs = [] 40 | 41 | if(update) { 42 | /** 43 | * This contains a freshly requested show list which could have the last_watched_at property of one or more shows updated and/or one or more additional shows at the start of the list. In the first case, the order of the already stored shows changed. In the second case, all already stored shows are shifted down by some indices. */ 44 | 45 | let updated = await getShowList(true) 46 | 47 | updated.forEach(item => { 48 | let oldIndex = visible.map(v => { 49 | return v.show.ids.trakt 50 | }).indexOf(item.show.ids.trakt) 51 | 52 | // store show id if it already exists but had updated watching progress 53 | if(oldIndex > -1) { 54 | if(visible[oldIndex].last_watched_at !== item.last_watched_at) { 55 | updatedIDs.push(item.show.ids.trakt) 56 | } 57 | } 58 | }) 59 | 60 | // overwrite old data with new 61 | visible = updated 62 | } else { 63 | visible = await getShowList() 64 | } 65 | 66 | let list = [] 67 | 68 | for(let i=0; i { 95 | return requestShowList() 96 | }, true) 97 | } 98 | 99 | function getShowProgress(id, update) { 100 | if(update) { 101 | flushCache('showProgress', id) 102 | } 103 | 104 | return cacheRequest('showProgress', id, () => { 105 | return requestShowProgress(id) 106 | }, false) 107 | } 108 | 109 | function getSeasonList(id, update) { 110 | if(update) { 111 | flushCache('seasonList', id) 112 | } 113 | 114 | return cacheRequest('seasonList', id, () => { 115 | return requestSeasonList(id) 116 | }, true) 117 | } 118 | 119 | function getEpisodeData(id, season, episode, update) { 120 | let cacheId = id+'_'+season+'_'+episode 121 | 122 | if(update) { 123 | flushCache('episodeData', cacheId) 124 | } 125 | 126 | return cacheRequest('episodeData', cacheId, () => { 127 | return requestEpisodeData(id, season, episode) 128 | }, true) 129 | } 130 | 131 | function getShowImages(id, update) { 132 | if(update) { 133 | flushCache('images', id) 134 | } 135 | 136 | return cacheRequest('images', id, () => { 137 | return requestShowImages(id) 138 | }, true) 139 | } 140 | -------------------------------------------------------------------------------- /modules/api/requesters.js: -------------------------------------------------------------------------------- 1 | const { 2 | debugLog 3 | } = require('./../helper.js') 4 | 5 | 6 | module.exports = { 7 | requestShowList, 8 | requestShowProgress, 9 | requestSeasonList, 10 | requestEpisodeList, 11 | requestEpisodeData, 12 | requestShowImages 13 | } 14 | 15 | 16 | function requestShowList() { 17 | function filterAndSortShows(a, h) { 18 | /** 19 | * Pay attention as the order in which the shows are sorted is by the date of last interaction. This interaction is not always a newly added episode but might also be a comment, rating or anything else unrelated to the watching history. Thus, it is not guaranteed that the order in which they appear in this list is the order of watching. */ 20 | let hiddenIds = h.map(item => item.show.ids.trakt) 21 | let visible = a.filter(item => { 22 | return !hiddenIds.includes(item.show.ids.trakt) 23 | }) 24 | 25 | visible.sort((a, b) => { 26 | let aTime = new Date(a.last_watched_at).valueOf() 27 | let bTime = new Date(b.last_watched_at).valueOf() 28 | 29 | if(aTime > bTime) return -1 30 | if(aTime < bTime) return 1 31 | return 0 32 | }) 33 | 34 | return visible 35 | } 36 | 37 | /** return[]: 38 | * last_updated_at 39 | * last_watched_at 40 | * plays 41 | * reset_at 42 | * seasons[]: 43 | * episodes[]: 44 | * last_watched_at 45 | * number 46 | * plays 47 | * number 48 | * show: 49 | * ids: 50 | * imdb, slig, tmdb, trakt, tvdb, tvrange 51 | * title 52 | * year 53 | */ 54 | return new Promise(async (resolve, rej) => { 55 | let all = await requestWatchedShows() 56 | let hidden = await requestHiddenItems() 57 | let visible = filterAndSortShows(all, hidden) 58 | 59 | resolve(visible) 60 | }) 61 | } 62 | 63 | 64 | // TRAKT 65 | 66 | function requestShowProgress(id) { 67 | /** return: 68 | * aired 69 | * completed 70 | * hidden_seasons[]: 71 | * number 72 | * ids[]: 73 | * trakt, tvdb, tmdb, imdb 74 | * last_episode[]: 75 | * season 76 | * number 77 | * title 78 | * ids[]: 79 | * trakt, tvdb, tmdb, imdb 80 | * last_watched_at 81 | * next_episode[]: 82 | * season 83 | * number 84 | * title 85 | * ids[]: 86 | * trakt, tvdb, tmdb, imdb 87 | * reset_at 88 | * seasons[]: 89 | * number 90 | * aired 91 | * completed 92 | * episodes[]: 93 | * number 94 | * completed 95 | * last_watched_at 96 | */ 97 | debugLog('api request', 'trakt') 98 | let requestTime = Date.now() 99 | return trakt.shows.progress.watched({ 100 | id: id, 101 | extended: 'full' 102 | }).then(res => { 103 | debugLog('requesting time', Date.now()-requestTime) 104 | return res 105 | }) 106 | } 107 | 108 | function requestWatchedShows() { 109 | debugLog('api request', 'trakt') 110 | let requestTime = Date.now() 111 | return trakt.sync.watched({ 112 | type: 'shows' 113 | }).then(res => { 114 | debugLog('requesting time', Date.now()-requestTime) 115 | return res 116 | }) 117 | } 118 | 119 | function requestHiddenItems() { 120 | debugLog('api request', 'trakt') 121 | let requestTime = Date.now() 122 | return trakt.users.hidden.get({ 123 | section: 'progress_watched', 124 | limit: 100 125 | }).then(res => { 126 | debugLog('requesting time', Date.now()-requestTime) 127 | return res 128 | }) 129 | } 130 | 131 | function requestSeasonList(id) { 132 | /** 133 | * []: 134 | * number 135 | * ids: trakt, tvdb, tmdb 136 | * rating 137 | * votes 138 | * episode_count 139 | * aired_episodes 140 | * title 141 | * overview 142 | * first_aired 143 | * network 144 | */ 145 | debugLog('api request', 'trakt') 146 | let requestTime = Date.now() 147 | return trakt.seasons.summary({ 148 | id: id, // showid 149 | extended: 'full' 150 | }).then(res => { 151 | debugLog('requesting time', Date.now()-requestTime) 152 | return res 153 | }) 154 | } 155 | 156 | function requestEpisodeList(id, season) { 157 | debugLog('api request', 'trakt') 158 | let requestTime = Date.now() 159 | return trakt.seasons.season({ 160 | id: id, 161 | season: season 162 | }).then(res => { 163 | debugLog('requesting time', Date.now()-requestTime) 164 | return res 165 | }) 166 | } 167 | 168 | function requestEpisodeData(id, season, episode) { 169 | debugLog('api request', 'trakt') 170 | let requestTime = Date.now() 171 | return trakt.episodes.summary({ 172 | id: id, 173 | season: season, 174 | episode: episode, 175 | extended: 'full' 176 | }).then(res => { 177 | debugLog('requesting time', Date.now()-requestTime) 178 | return res 179 | }) 180 | } 181 | 182 | 183 | // FANART 184 | 185 | function requestShowImages(id) { 186 | // id is tvdb-id 187 | debugLog('api request', 'fanart') 188 | let requestTime = Date.now() 189 | return fanart.shows.get(id).then(res => { 190 | debugLog('requesting time', Date.now()-requestTime) 191 | return res 192 | }) 193 | } 194 | -------------------------------------------------------------------------------- /modules/cache.js: -------------------------------------------------------------------------------- 1 | /* 2 | This module implements a caching class which can be used dynamically over all pages. 3 | */ 4 | 5 | const flatCache = require('flat-cache') 6 | const path = require('path') 7 | 8 | const { 9 | config, debugLog 10 | } = require('./helper.js') 11 | 12 | module.exports = class Cache { 13 | constructor(name, cacheTime=0) { 14 | this.name = name 15 | this.path = path.join(__dirname, config.client.cache.path) 16 | this.cache = flatCache.load(name, this.path) 17 | this.expire = cacheTime === 0 ? false : cacheTime * 1000 * 60 18 | } 19 | getKey(key) { 20 | let now = new Date().getTime() 21 | let value = this.cache.getKey(key) 22 | if(value === undefined || (value.expire !== false && value.expire < now)) { 23 | return undefined 24 | } else { 25 | return value.data 26 | } 27 | } 28 | setKey(key, value) { 29 | let now = new Date().getTime() 30 | this.cache.setKey(key, { 31 | expire: this.expire === false ? false : now + this.expire, 32 | data: value 33 | }) 34 | } 35 | removeKey(key) { 36 | this.cache.removeKey(key) 37 | } 38 | save() { 39 | let timer = Date.now() 40 | debugLog('cache', 'saving...') 41 | this.cache.save(true) 42 | debugLog('cache', `saved in ${Date.now() - timer}ms`) 43 | } 44 | remove() { 45 | flatCache.clearCacheById(this.name, this.path) 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /modules/helper.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | 3 | const config = JSON.parse(fs.readFileSync("./config.json", "utf8")) 4 | 5 | const LogQueue = new(require(__dirname+'/queue.js'))({ 6 | frequency: 5, 7 | reverse: true 8 | }) 9 | 10 | 11 | class IPCChannels { 12 | constructor() {} 13 | log(details) { 14 | switch(details.action) { 15 | case 'save': { 16 | let logPath = './.log' 17 | LogQueue.add(function() { 18 | fs.stat(logPath, function(err, stat) { 19 | if(err == null) { 20 | let currentLog = fs.readFileSync(logPath) 21 | fs.writeFileSync(logPath, currentLog+'\n'+details.log) 22 | } else if(err.code == 'ENOENT') { 23 | // file does not exist yet 24 | fs.writeFileSync(logPath, 'TRAKTIFY LOG\n'+details.log) 25 | } else { 26 | console.log('Error occured while saving log: ', err.code) 27 | } 28 | }) 29 | }) 30 | 31 | break 32 | } 33 | 34 | case 'print': { 35 | printLog(String(details.log).split(','), details.date) 36 | 37 | break 38 | } 39 | } 40 | } 41 | } 42 | 43 | class IPCParallel { 44 | send(channel, details) { 45 | ipcChannels[channel](details) 46 | } 47 | } 48 | 49 | const ipcChannels = new IPCChannels() 50 | const ipcParallel = new IPCParallel() 51 | 52 | 53 | function printLog(args, date) { 54 | date = new Date(date) 55 | let time = `${ 56 | date.getHours().toString().length === 1 57 | ? '0'+date.getHours() : date.getHours() 58 | }:${ 59 | date.getMinutes().toString().length === 1 60 | ? '0'+date.getMinutes() : date.getMinutes() 61 | }:${ 62 | date.getSeconds().toString().length === 1 63 | ? '0'+date.getSeconds() : date.getSeconds() 64 | }` 65 | 66 | if(args[0] == 'err' || args[0] == 'error') { 67 | console.log(`\x1b[41m\x1b[37m${time} -> ${args[0]}:\x1b[0m`, args[1]) 68 | if(args[2]) { 69 | console.log(` @ .${args[2].toString().split(/\r\n|\n/)[1].split('traktify')[1].split(')')[0]}`) 70 | } 71 | } else { 72 | let bgColor = '\x1b[47m' 73 | let title = args[0].split('') 74 | if(title[0] === '!') { 75 | title[0] = '' 76 | bgColor = '\x1b[43m' 77 | } 78 | title = title.join('') 79 | console.log(`${bgColor}\x1b[30m${time} -> ${title}:\x1b[0m`, args[1]) 80 | if(args.length > 2) { 81 | console.log.apply(null, args.splice(2, args.length-2)) 82 | } 83 | } 84 | } 85 | 86 | 87 | function debugLog(...args) { 88 | let ipc 89 | if(typeof ipcRenderer === 'undefined') { 90 | ipc = ipcParallel 91 | } else { 92 | ipc = ipcRenderer 93 | } 94 | 95 | let date = new Date() 96 | 97 | // printing to the terminal if in development mode 98 | if(process.env.NODE_ENV !== 'production') { 99 | ipc.send('log', { 100 | action: 'print', 101 | log: args, 102 | date: date 103 | }) 104 | } 105 | 106 | // log is always saved to disk 107 | ipc.send('log', { 108 | action: 'save', 109 | log: date.toISOString() 110 | .split('T').join(' ') 111 | .split('Z').join('') 112 | +': '+args 113 | }) 114 | } 115 | 116 | // Range must be an array of two numeric values 117 | function inRange(value, range) { 118 | let [min, max] = range; max < min ? [min, max] = [max, min] : [min, max] 119 | return value >= min && value <= max 120 | } 121 | 122 | // takes a hex color code and changes it's brightness by the given percentage. Positive value to brighten, negative to darken a color. Percentages are taken in range from 0 to 100 (not 0 to 1!). 123 | // function mainly used to generate dark version of the accent colors 124 | function shadeHexColor(hex, percent) { 125 | // convert hex to decimal 126 | let R = parseInt(hex.substring(1,3), 16) 127 | let G = parseInt(hex.substring(3,5), 16) 128 | let B = parseInt(hex.substring(5,7), 16) 129 | 130 | // change by given percentage 131 | B = parseInt(B*(100 + percent)/100) 132 | R = parseInt(R*(100 + percent)/100) 133 | G = parseInt(G*(100 + percent)/100) 134 | 135 | // clip colors to max value 136 | R = R<255 ? R : 255 137 | G = G<255 ? G : 255 138 | B = B<255 ? B : 255 139 | 140 | // zero-ize single-digit values 141 | let RR = R.toString(16).length==1 ? '0'+R.toString(16) : R.toString(16) 142 | let GG = G.toString(16).length==1 ? '0'+G.toString(16) : G.toString(16) 143 | let BB = B.toString(16).length==1 ? '0'+B.toString(16) : B.toString(16) 144 | 145 | return '#'+RR+GG+BB 146 | } 147 | 148 | // Simple helper to clone objects which prevents cross-linking. 149 | function clone(object) { 150 | if(null == object || "object" != typeof object) return object 151 | // create new blank object of same type 152 | let copy = object.constructor() 153 | 154 | // copy all attributes into it 155 | for(let attr in object) { 156 | if(object.hasOwnProperty(attr)) { 157 | copy[attr] = object[attr] 158 | } 159 | } 160 | return copy 161 | } 162 | 163 | 164 | module.exports = { 165 | config, printLog, debugLog, inRange, shadeHexColor, clone, ipcChannels, ipcParallel 166 | } 167 | -------------------------------------------------------------------------------- /modules/presence.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | const discordRPC = require('discord-rpc') 3 | const EventEmitter = require('events') 4 | const debugLog = remote.getGlobal('debugLog') 5 | 6 | 7 | module.exports = function(clientId) { 8 | const rpc = new discordRPC.Client({ transport: 'ipc' }) 9 | 10 | let connected = false 11 | let activityCache = null 12 | 13 | const instance = new class RP extends EventEmitter { 14 | updatePresence(d) { 15 | if(connected) { 16 | rpc.setActivity(d).catch(err => debugLog('error', '', new Error().stack)) 17 | } else { 18 | activityCache = d 19 | } 20 | } 21 | 22 | disconnect() { 23 | rpc.destroy().catch(err => debugLog('error', '', new Error().stack)) 24 | } 25 | }() 26 | 27 | rpc.on('error', err => debugLog('error', '', new Error().stack)) 28 | 29 | rpc.login({ clientId }).then(() => { 30 | debugLog('rpc', 'connected') 31 | connected = true 32 | 33 | if(activityCache) { 34 | rpc.setActivity(activityCache) 35 | .catch(err => debugLog('error', '', new Error().stack)) 36 | activityCache = null 37 | } 38 | }).catch(err => debugLog('error', 'rpc login', new Error().stack)) 39 | 40 | return instance 41 | } 42 | -------------------------------------------------------------------------------- /modules/queue.js: -------------------------------------------------------------------------------- 1 | module.exports = class Queue { 2 | constructor(options) { 3 | // options: { ?frequency, ?reverse } 4 | options = options || {} 5 | 6 | if(options.frequency) { 7 | this._timeOut = 1e3/options.frequency 8 | } else { 9 | this._timeOut = 1e3 10 | } 11 | 12 | this._reverse = options.reverse 13 | 14 | this._taskList = [] 15 | } 16 | 17 | add(callback, options) { 18 | // options: { ?args, ?overwrite } 19 | options = options || {} 20 | let job = new Task(options.args, callback) 21 | 22 | if(options.overwrite) { 23 | let duplicates = this._duplicates(job) 24 | duplicates.forEach(i => { 25 | this._taskList.splice(i, 1) 26 | }) 27 | } 28 | 29 | // enqueuing job 30 | this._enqueue(job) 31 | 32 | // start the queue if it hasn't already 33 | if(!this._interval) { 34 | // starting ticker 35 | this._interval = setInterval(() => { 36 | let result = this._doTick() 37 | if(!result) { 38 | // stopping ticker 39 | clearInterval(this._interval) 40 | this._interval = null 41 | } 42 | }, this._timeOut) 43 | } 44 | } 45 | 46 | _enqueue(job) { 47 | let len = this._taskList.push(job) 48 | } 49 | 50 | _doTick() { 51 | let jobIndex 52 | if(this._reverse) { 53 | jobIndex = 0 54 | } else { 55 | jobIndex = this._taskList.length - 1 56 | } 57 | 58 | 59 | if(this._taskList.length === 0) { 60 | // no job available 61 | return null 62 | } else { 63 | // ticking 64 | let job = this._taskList[jobIndex] 65 | let result = job.run() 66 | .then(r => r) 67 | .catch(e => e) 68 | 69 | if(this._reverse) { 70 | this._taskList.shift() 71 | } else { 72 | this._taskList.pop() 73 | } 74 | 75 | return result 76 | } 77 | } 78 | 79 | _duplicates(j) { 80 | let indices = [] 81 | this._taskList.forEach((t, index) => { 82 | if( 83 | String(j.args) == String(t.args) 84 | && String(j.callback) == String(t.callback) 85 | ) { 86 | // found duplicate 87 | indices.push(index) 88 | } 89 | }) 90 | return indices 91 | } 92 | } 93 | 94 | 95 | class Task { 96 | constructor(args, callback) { 97 | this.time = Date.now() 98 | this.callback = callback 99 | this.args = args 100 | } 101 | 102 | run() { 103 | return new Promise(async (resolve, rej) => { 104 | let result 105 | try { 106 | result = await this.callback(this.args) 107 | } catch(err) { 108 | if(err) { 109 | rej(err) 110 | } 111 | } 112 | resolve(result) 113 | }) 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /modules/request.js: -------------------------------------------------------------------------------- 1 | let fanart = remote.getGlobal('fanart') 2 | let config = remote.getGlobal('config') 3 | 4 | module.exports = { 5 | newActivitiesAvailable, 6 | searchRequestHelper, 7 | getUserStats, 8 | getSeasonPoster, 9 | reloadAllItems 10 | } 11 | 12 | const { 13 | debugLog 14 | } = require('./helper.js') 15 | 16 | 17 | //:::: SYNCING ::::\\ 18 | let syncingCache = new Cache('syncing') 19 | 20 | // returns an array of activity keys that have unseen activity 21 | async function newActivitiesAvailable() { 22 | syncingCache.remove() 23 | syncingCache.save() 24 | 25 | let latest = await getLatestActivities() 26 | debugLog('request finished', latest.all) 27 | let cacheContent = syncingCache.getKey('latestActivities') 28 | 29 | if(cacheContent !== undefined) { 30 | debugLog('cache content', cacheContent.all) 31 | if(latest.all === cacheContent.all) { 32 | debugLog('latest activities', 'nothing new') 33 | return [] 34 | } else { 35 | let updates = [] 36 | for(let scope in cacheContent) { 37 | if(scope !== 'all') { 38 | for(let action in cacheContent[scope]) { 39 | let dateOld = cacheContent[scope][action] 40 | let dateNew = latest[scope][action] 41 | if(dateNew !== dateOld) { 42 | updates.push(scope) 43 | debugLog('latest activities', action+' @ '+scope) 44 | } 45 | } 46 | } 47 | } 48 | syncingCache.setKey('latestActivities', latest) 49 | syncingCache.save() 50 | 51 | return updates 52 | } 53 | } else { 54 | debugLog('latest activities', 'all are unseen') 55 | // save latest activities if its the first time caching it 56 | syncingCache.setKey('latestActivities', latest) 57 | syncingCache.save() 58 | 59 | return [ 'movies', 'episodes', 'shows', 'seasons', 'comments', 'lists' ] 60 | } 61 | } 62 | 63 | function getLatestActivities() { 64 | debugLog('api request', 'latest trakt activites') 65 | return trakt.sync.last_activities().then(res => res) 66 | } 67 | 68 | // This function is triggered when hitting the reload button on the dashboard. 69 | async function reloadAllItems(_this, _par, onStart, onFinish) { 70 | onStart(_this) 71 | await generatePosterSection(true) 72 | onFinish(_this, _par.children[0]) 73 | } 74 | 75 | 76 | //:::: IMAGE REQUESTING ::::\\ 77 | let imageCache = new Cache('images') 78 | 79 | async function getSeasonPoster(showId, season) { 80 | let cacheKey = showId+'_'+season 81 | let cacheContent = imageCache.getKey(cacheKey) 82 | 83 | if(cacheContent === undefined) { 84 | let data = await requestSeasonPoster(showId, season) 85 | debugLog('caching', cacheKey) 86 | 87 | ipcRenderer.send('cache', { 88 | action: 'addKey', 89 | name: 'images', 90 | key: cacheKey, 91 | data: data 92 | }) 93 | 94 | ipcRenderer.send('cache', { 95 | action: 'saveKeys', 96 | name: 'images' 97 | }) 98 | 99 | return data 100 | } else { 101 | debugLog('cache available', cacheKey) 102 | return cacheContent 103 | } 104 | } 105 | 106 | function requestSeasonPoster(showId, season) { 107 | return fanart.shows.get(showId).then(async result => { 108 | function currentSeasonPoster() { 109 | let index = -1 110 | let first = true 111 | let preindex = -1 112 | for(let i in result.seasonposter) { 113 | let poster = result.seasonposter[i] 114 | if(poster.season == season) { 115 | if(poster.lang == 'en') { 116 | debugLog('poster', 'found fitting') 117 | return i 118 | } 119 | if(first) { 120 | preindex = i 121 | first = false 122 | } 123 | } 124 | } 125 | if(preindex > -1) { 126 | debugLog('poster', 'only found different language') 127 | return preindex 128 | } else { 129 | debugLog('poster', 'did not found correct season') 130 | return index 131 | } 132 | } 133 | 134 | let url = '' 135 | 136 | function fallback() { 137 | if(Object.keys(result).includes('tvposter')) { 138 | debugLog('poster', 'placing tv poster as fallback') 139 | url = result.tvposter[0].url 140 | } else { 141 | debugLog('poster', 'replacing unavailable poster') 142 | url = '../../assets/'+config.client.placeholder.poster 143 | } 144 | } 145 | 146 | if(Object.keys(result).includes('seasonposter')) { 147 | let index = await currentSeasonPoster() 148 | if(index > -1) { 149 | url = result.seasonposter[index].url 150 | } else { 151 | fallback() 152 | } 153 | } else { 154 | fallback() 155 | } 156 | 157 | return url 158 | }).catch(() => { 159 | debugLog('error', 'fanart not found', new Error().stack) 160 | return '../../assets/'+config.client.placeholder.poster 161 | }) 162 | } 163 | 164 | //:::: SEARCH ::::\\ 165 | let searchQueryCache = new Cache('searchQuery') 166 | 167 | function searchRequestHelper(text) { 168 | let cacheContent = searchQueryCache.getKey(text) 169 | // check if text was searched already, send earlier results if so 170 | if(cacheContent !== undefined) { 171 | debugLog('cache content', cacheContent) 172 | return Promise.resolve(cacheContent) 173 | } 174 | 175 | let query = formatSearch(text) 176 | if(!query) return null 177 | 178 | return trakt.search.text({ 179 | type: query.type, 180 | query: query.filtered, 181 | extended: 'full' 182 | }) 183 | // got search results from trakt 184 | .then(searchResults => { 185 | debugLog('api request', 'trakt') 186 | debugLog('search results', searchResults.map(r => r[r.type].ids.trakt)) 187 | 188 | return new Promise((resolve, reject) => { 189 | let fanartQueue = [] 190 | 191 | searchResults.forEach(result => { 192 | let mv = result.type == 'movie' ? 'm' : 'v' 193 | 194 | fanartQueue.push(fanart[result.type + 's'] 195 | .get(result[result.type].ids['t' + mv + 'db']) 196 | // got data from fanart 197 | .then(fanResult => { 198 | debugLog('api request', 'fanart') 199 | return fanResult 200 | }).catch(err => debugLog('error', 'fanart', new Error().stack)) 201 | ) 202 | }) 203 | 204 | resolve([searchResults, fanartQueue]) 205 | }) 206 | // search queue filled with promises 207 | .then(([trakt, fanart]) => { 208 | debugLog('queue', 'starting') 209 | 210 | return Promise.all(fanart.map(p => p.catch(e => e))) 211 | .then(resolvedQueue => { 212 | let requestArray = [] 213 | 214 | resolvedQueue.forEach((item, index) => { 215 | requestArray.push({ 216 | trakt: trakt[index], 217 | fanart: item 218 | }) 219 | }) 220 | 221 | let data = { 222 | date: Date.now(), 223 | result: requestArray 224 | } 225 | 226 | debugLog('caching', text) 227 | 228 | ipcRenderer.send('cache', { 229 | action: 'addKey', 230 | name: 'searchQuery', 231 | key: text, 232 | data: data 233 | }) 234 | 235 | ipcRenderer.send('cache', { 236 | action: 'save', 237 | name: 'searchQuery' 238 | }) 239 | 240 | return data 241 | }) 242 | .catch(err => { 243 | debugLog('error', 'promise', new Error().stack) 244 | }) 245 | }) 246 | }).catch(err => { 247 | debugLog('error', 'trakt', new Error().stack) 248 | }) 249 | } 250 | 251 | // To sent API requests, we need a properly formed query. This formats the raw input text into a usable object. 252 | function formatSearch(text) { 253 | let searchOptions = [ 254 | 's', 'show', 'shows', 'tv', 255 | 'm', 'movie', 256 | 'e', 'ep', 'episode', 257 | 'p', 'person' 258 | ].map(o => o + ':') 259 | 260 | let query = startsWithFilter(text, searchOptions, ':') 261 | 262 | // This converts the simplified search type into a request-friendly one 263 | switch(query.found) { 264 | case 's': 265 | case 'show': 266 | case 'shows': 267 | case 'tv': { 268 | query.type = 'show' 269 | break 270 | } 271 | case 'm': 272 | case 'movie': { 273 | query.type = 'movie' 274 | break 275 | } 276 | case 'e': 277 | case 'ep': 278 | case 'episode': { 279 | query.type = 'episode' 280 | break 281 | } 282 | case 'p': 283 | case 'person': { 284 | query.type = 'person' 285 | break 286 | } 287 | default: { 288 | break 289 | } 290 | } 291 | 292 | debugLog('query', query) 293 | return query 294 | } 295 | 296 | function startsWithFilter(string, options, removeFromFilter) { 297 | string = string.toString() 298 | for(let opt in options) { 299 | if(string.startsWith(options[opt])) { 300 | return { 301 | found: options[opt].split(removeFromFilter || '').join(''), 302 | filtered: string.split(options[opt])[1] 303 | } 304 | } 305 | } 306 | 307 | return { 308 | found: null, 309 | filtered: string 310 | } 311 | } 312 | 313 | 314 | //:::: STATS ::::\\ 315 | 316 | // we're using the syncingCache from above here 317 | async function getUserStats() { 318 | let cacheContent = syncingCache.getKey('userStats') 319 | if(cacheContent === undefined) { 320 | return requestUserStats().then(userStats => { 321 | debugLog('caching', 'user stats') 322 | 323 | ipcRenderer.send('cache', { 324 | action: 'addKey', 325 | name: 'syncing', 326 | key: 'userStats', 327 | data: userStats 328 | }) 329 | 330 | ipcRenderer.send('cache', { 331 | action: 'save', 332 | name: 'syncing' 333 | }) 334 | 335 | return userStats 336 | }) 337 | } else { 338 | // In this case, everything that was cached is uptodate 339 | debugLog('cache available', 'user stats') 340 | return cacheContent 341 | } 342 | } 343 | 344 | function requestUserStats() { 345 | debugLog('api request', 'user stats') 346 | let requestTime = Date.now() 347 | return new Promise(async (resolve, reject) => { 348 | let userSettings = await getUserSettings() 349 | resolve( 350 | trakt.users.stats({ 351 | username: userSettings.user.username 352 | }).then(res => { 353 | debugLog('request finished', Date.now()-requestTime) 354 | return res 355 | }) 356 | ) 357 | }) 358 | } 359 | 360 | async function getUserSettings() { 361 | let userSettings = await requestUserSettings() 362 | return userSettings 363 | } 364 | 365 | function requestUserSettings() { 366 | debugLog('api request', 'user information') 367 | let requestTime = Date.now() 368 | return trakt.users.settings().then(res => { 369 | debugLog('request finished', Date.now()-requestTime) 370 | return res 371 | }) 372 | } 373 | 374 | // 375 | // BUFFER 376 | // 377 | 378 | const { 379 | getShowList, 380 | getEpisodeData, 381 | getSeasonList, 382 | getShowImages 383 | } = require('./api/getters.js') 384 | 385 | async function convertTraktToTvdb(id) { 386 | return await getShowList().then(list => { 387 | let trakt = list.map(s => s.show.ids.trakt) 388 | let i = trakt.indexOf(Number(id)) 389 | return list[i].show.ids.tvdb 390 | }) 391 | } 392 | 393 | /** 394 | * This buffer instance handles the traffic between API, Cache and renderer. 395 | * It is mainly used for the card slider which openes when clicked on a episode poster. 396 | * Inside this slider the user is able to move back and forth through every episode of the tv show. Loading the data for all episodes at once would take first of all way too much time and secondly it would stress the APIs too much which would lead to rate limiting. 397 | * The buffer is meant to reduce these load times and stresses to make the user experice fluid and snappy. 398 | * 399 | * A buffer instance is unique to one show. To buffer another show, the instance would have to be either overwritten or a new one must be created. 400 | */ 401 | module.exports.showBuffer = class showBuffer { 402 | constructor(showId) { 403 | debugLog('buffer', 'creating new instance for '+showId) 404 | this.id = showId 405 | this.tvdb = convertTraktToTvdb(this.id) 406 | 407 | this.show = { 408 | seasons: [] 409 | } 410 | // array of episode_count 411 | this.tree = [] 412 | // plain 1D array with a list of all items 413 | this.items = [] 414 | // current position 415 | this.current = 0 416 | 417 | // indices that are in queue to be requested 418 | this.queue = [] 419 | 420 | this.timer = 0 421 | } 422 | 423 | /** 424 | * Initialize an array with an element for each episode. 425 | * @param {Number} size Total size of TV show 426 | */ 427 | applySize(size) { 428 | for(let i=0; i { 442 | if(i < s) { 443 | // add all episodes on seasons below current 444 | abs += c 445 | } 446 | }) 447 | 448 | // add pos inside current season 449 | abs += e 450 | return abs 451 | } 452 | 453 | /** 454 | * Initialize the buffer at a given point in the TV show. 455 | * @param {Number} s Number of the season 456 | * @param {Number} e Number of the episode 457 | * @param {Function} on.size Callback when the size of the entire show is known 458 | * @param {Function} on.first Callback when full data for the current episode is available 459 | * @param {Function} on.buffer Callback when one episode from the buffer area got requested. Will trigger once for each element. 460 | */ 461 | async initAt(s, e, on) { 462 | s = Number(s) // prevent string addition 463 | e = Number(e) 464 | debugLog('!buffer', 'initializing new instance') 465 | this.timer = Date.now() 466 | 467 | await getEpisodeData(this.id, s, e).then(async d => { 468 | // We first have to know the length of the season. With that information, we can add the required amount of cards to the stack—which will be done by the renderer receiving the callback. 469 | await getSeasonList(this.id).then(seasons => { 470 | this.show.seasons = seasons 471 | 472 | let total = 0 473 | seasons.forEach(el => { 474 | if(el.title != 'Specials') { 475 | // counting episodes but ignoring specials 476 | total += el.episode_count 477 | this.tree.push(el.episode_count) 478 | } 479 | }) 480 | 481 | let size = { 482 | total: total, 483 | current: this.posToAbs(s, e) 484 | } 485 | 486 | this.current = size.current 487 | 488 | on.size(size) 489 | this.applySize(total) 490 | }) 491 | 492 | // Now, we can send the episode data back via the callback and save it to the local scope. 493 | await on.first(this.formatUpdates(d)) 494 | this.items[this.current-1] = { data: d, images: null } 495 | 496 | this.updateBuffer(this.current, on) 497 | }) 498 | 499 | on.images(await this.requestImages(this.current), this.current-1) 500 | } 501 | 502 | /** 503 | * Restore the buffer at a given point in the TV show. Restoration is faster than initialization. 504 | * @param {Number} s Number of the season 505 | * @param {Number} e Number of the episode 506 | * @param {Function} on.size Callback when the size of the entire show is known 507 | * @param {Function} on.first Callback when full data for the current episode is available 508 | * @param {Function} on.buffer Callback when one episode from the buffer area got requested. Will trigger once for each element. 509 | */ 510 | async openAt(s, e, on) { 511 | s = Number(s) 512 | e = Number(e) 513 | debugLog('!buffer', 'opening existing instance') 514 | this.timer = Date.now() 515 | 516 | this.current = this.posToAbs(s, e) 517 | 518 | on.size({ 519 | total: this.tree.reduce((p, c) => p + c), 520 | current: this.current 521 | }) 522 | 523 | let firstData = await this.requestEpisode(this.current) 524 | let firstRes = this.formatUpdates(firstData) 525 | on.first(firstRes) 526 | 527 | this.updateBuffer(this.current, on) 528 | } 529 | 530 | /** 531 | * Moving through the buffer by a certain amount. To prevent buffer piling, this function has to be called in delay with the total movement happened during that delay. 532 | * @param {Number} dir The amount of episodes to move, negative to move backwards. 533 | * @param {Function} on.first Callback for the data of the seen item. 534 | * @param {Function} on.buffer Callback for the buffered items. 535 | */ 536 | move(dir, on) { 537 | debugLog('!buffer', `moving ${dir>0 ? 'right' : 'left'}`) 538 | this.timer = Date.now() 539 | let newPos = this.current + dir 540 | 541 | // The new buffer position would be out of range. I hope this will never happen but in case it does, we'll clip it to the max or min. 542 | if(newPos < 1) { 543 | newPos = 1 544 | } else if(newPos > this.items.length) { 545 | newPos = this.items.length 546 | } 547 | 548 | // update the current position in the buffer 549 | this.current = newPos 550 | this.updateBuffer(newPos, on) 551 | } 552 | 553 | /** 554 | * Updates the buffer to the new position and calculates the surrounding area to request. 555 | * @param {Number} pos New position to move to, absolute number. 556 | * @param {Function} on.first Callback for the data of the seen item. 557 | * @param {Function} on.buffer Callback for the buffered items. 558 | */ 559 | updateBuffer(pos, on) { 560 | let range = [0, 1, -1, 2, -2] 561 | range.forEach(r => { 562 | let epPos = pos+r 563 | // the absolute positions can't become smaller than 1 or greater the show length 564 | if(epPos > 0 && epPos <= this.items.length) { 565 | this.queue.push(epPos) 566 | } 567 | }) 568 | 569 | this.nextInQueue(on) 570 | } 571 | 572 | /** 573 | * Recursive function that runs over the queue list where items were added by the updateBuffer() function. 574 | * @param {Function} on.first Callback for the data of the seen item. 575 | * @param {Function} on.buffer Callback for the buffered items. 576 | */ 577 | async nextInQueue(on) { 578 | if(this.queue.length > 0) { 579 | let reqPos = this.queue[0] 580 | // remove it and possible dublicates from the queue 581 | this.queue = this.queue.filter(q => q != reqPos) 582 | 583 | let epData = this.formatUpdates(await this.requestEpisode(reqPos)) 584 | 585 | if(reqPos == this.current) { 586 | on.first(epData) 587 | } else { 588 | on.buffer(epData, reqPos-1) 589 | } 590 | 591 | on.images(await this.requestImages(reqPos), reqPos-1) 592 | 593 | // some time delay to allow flushing the quere 594 | setTimeout(() => { 595 | this.nextInQueue(on) 596 | }, 200) 597 | } else { 598 | // stop timer 599 | debugLog('!buffer', Date.now()-this.timer + 'ms') 600 | } 601 | } 602 | 603 | flushQueue() { 604 | // This empties the queue, it does not fully kill the requesting process if some is currently running! The flushing is possible since the requesting queue is delayed after each finished item. 605 | this.queue = [] 606 | } 607 | 608 | /** 609 | * Sends back data for a given episode number. It will automatically check for available buffer and cache data. Only if nothing is saved already, the API will be used to get the data. 610 | * @param {Number} pos Absolute position of the episode in the show. 611 | */ 612 | requestEpisode(pos) { 613 | if(this.items[pos-1] == null) { 614 | // a simple helper 615 | let counter = 0 616 | 617 | // these two will be determined in the following loop 618 | let seasonIndex = 0 619 | let episodeIndex = 0 620 | for(let i=0; i { 632 | this.items[pos-1] = { data: epData } 633 | return epData 634 | }) 635 | } else { 636 | // item was already buffered before 637 | debugLog('!buffer', `restoring item ${pos-1}`) 638 | return Promise.resolve(this.items[pos-1].data) 639 | } 640 | } 641 | 642 | /** 643 | * Send back object of image URLs for a given item in the buffer. Through a getter function the API and Cache load will be balanced automatically. Result is in form of a promise. 644 | * @param {Number} pos Absolute position of the episode in the show. 645 | */ 646 | async requestImages(pos) { 647 | if(this.items[pos-1].images == null) { 648 | // no images buffered 649 | return getShowImages(await this.tvdb).then(r => { 650 | this.items[pos-1].images = { 651 | banner: r.tvbanner[0].url, 652 | poster: r.tvposter[0].url 653 | } 654 | return this.items[pos-1].images 655 | }) 656 | } else { 657 | // images were buffered before 658 | return Promise.resolve(this.items[pos-1].images) 659 | } 660 | } 661 | 662 | formatUpdates(raw) { 663 | return { 664 | ratingPercent: ''+Math.round(raw.rating * 10), 665 | episodeTitle: raw.title, 666 | episodeNumber: (() => { 667 | let num = ''+raw.number 668 | if(num.length < 2) { 669 | num = '0'+num 670 | } 671 | return num 672 | })(), 673 | seasonNumber: ''+raw.season, 674 | absoluteNumber: ''+raw.number_abs, 675 | description: raw.overview 676 | } 677 | } 678 | } 679 | -------------------------------------------------------------------------------- /modules/rpc.js: -------------------------------------------------------------------------------- 1 | let config = remote.getGlobal('config') 2 | let on = config.client.settings.app['discord rpc'].status 3 | 4 | let client = require('./presence.js') 5 | 6 | if(on) client = client(process.env.discord_key) 7 | 8 | module.exports = { 9 | update: update 10 | } 11 | 12 | async function update(options) { 13 | options = await options 14 | debugLog('rpc status', options.state) 15 | 16 | let total = ((options.time.movies+options.time.shows)/60).toFixed(1) 17 | 18 | client.updatePresence({ 19 | details: `watched for ${total} hours`, 20 | state: options.state, 21 | largeImageKey: 'trakt', 22 | largeImageText: 'traktify', 23 | instance: false 24 | }) 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "traktify", 3 | "version": "0.1.8", 4 | "description": "multi platform desktop app for trakt.tv", 5 | "main": "app.js", 6 | "scripts": { 7 | "start": "electron .", 8 | "clearCache": "rm -f -r ./.cache/" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/CodingBobby/traktify.git" 13 | }, 14 | "keywords": [ 15 | "trakt", 16 | "desktop", 17 | "app" 18 | ], 19 | "author": "CodingBobby, Bumbleboss", 20 | "license": "ISC", 21 | "bugs": { 22 | "url": "https://github.com/CodingBobby/traktify/issues" 23 | }, 24 | "homepage": "https://codingbobby.xyz/traktify", 25 | "devDependencies": { 26 | "electron": "^4.2.8" 27 | }, 28 | "dependencies": { 29 | "background": "^0.3.3", 30 | "discord-rpc": "^3.0.2", 31 | "electron-window-state": "^5.0.3", 32 | "fanart.tv": "^2.1.0", 33 | "flat-cache": "^2.0.1", 34 | "fs": "0.0.1-security", 35 | "moviedb-promise": "^1.4.1", 36 | "node-tvdb": "^4.1.0", 37 | "path": "^0.12.7", 38 | "register-scheme": "0.0.2", 39 | "request": "^2.88.0", 40 | "rimraf": "^3.0.0", 41 | "trakt.tv": "^7.2.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /pages/dashboard/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 27 | Traktify | Dashboard 28 | 29 | 30 |
31 |
32 | 33 | 57 |
58 | 59 |
    60 |
  • 61 |
  • 62 |
  • 63 |
  • 64 |
65 | 66 |
67 |
68 |
69 |
70 |
    71 |
    72 | 73 |
    74 | History 75 | 76 | 77 | 78 | 79 | -------------------------------------------------------------------------------- /pages/dashboard/index.js: -------------------------------------------------------------------------------- 1 | const trakt = remote.getGlobal('trakt') 2 | const fanart = remote.getGlobal('fanart') 3 | const getSettings = remote.getGlobal('getSettings') 4 | const setSetting = remote.getGlobal('setSetting') 5 | const defaultAll = remote.getGlobal('defaultAll') 6 | const updateApp = remote.getGlobal('updateApp') 7 | const relaunchApp = remote.getGlobal('relaunchApp') 8 | 9 | let config = remote.getGlobal('config') 10 | 11 | // Here we update the app with saved settings after the window is created 12 | window.onload = function() { 13 | debugLog('window', 'dashboard loading') 14 | updateApp() // update settings 15 | generatePosterSection() // show the up next to watch posters 16 | updateRpc() // show rpc on discord, handling the on/off setting is done within this function and doesn't have to be done here! 17 | } 18 | 19 | // This guy waits for messages on the 'modify-root' channel. The messages contain setting objects that then get applied to the 'master.css' style sheet. It is used to change the look of the app. 20 | ipcRenderer.on('modify-root', (event, data) => { 21 | let variables = document.styleSheets[0] 22 | .cssRules[0].style.cssText.split(';') 23 | 24 | let result = {} 25 | for(let i in variables) { 26 | let a = variables[i].split(':') 27 | if(a[0] !== '') { 28 | result[a[0].trim()] = a[1].trim() 29 | } 30 | } 31 | 32 | let keys = Object.keys(result) 33 | document.documentElement.style.setProperty(keys[keys.indexOf(data.name)], data.value) 34 | }) 35 | 36 | // Identifies the currently open panel. Makes it easier to check against closed panels, as this would require each of them to check on their own. 37 | let openedPanel = null 38 | /** 39 | * sidebar, 40 | * cards 41 | */ 42 | 43 | // Here, dashboard-wide shortcuts are defined. The 'meta' key represents CMD on macOS and Ctrl on Windows 44 | document.onkeydown = function() { 45 | if(event.metaKey && event.keyCode === 83) { // meta + S 46 | debugLog('shortcut', 'Meta + S') 47 | if(openedPanel !== 'cards') { 48 | show(document.getElementById('search_button_side')) 49 | triggerSidePanel('search') 50 | } 51 | return false 52 | } else if(event.metaKey && event.keyCode === 188) { // meta + , 53 | debugLog('shortcut', 'Meta + ,') 54 | if(openedPanel !== 'cards') { 55 | show(document.getElementById('settings_button_side')) 56 | triggerSidePanel('settings') 57 | } 58 | return false 59 | } else if(event.keyCode === 27) { // ESC 60 | debugLog('shortcut', 'ESC') 61 | // close the currently open panel 62 | if(openedPanel == 'sidebar') { 63 | triggerSidePanel(sideBar.status) 64 | } else if(openedPanel == 'cards') { 65 | triggerInfoCardOverlay() 66 | } 67 | } else if(event.keyCode === 39) { // arrow right 68 | debugLog('shortcut', 'ArrowRight') 69 | if(openedPanel == 'cards') { 70 | moveCards(null, 'right') 71 | } 72 | } else if(event.keyCode === 37) { // arrow left 73 | debugLog('shortcut', 'ArrowLeft') 74 | if(openedPanel == 'cards') { 75 | moveCards(null, 'left') 76 | } 77 | } else if(event.keyCode === 38) { // arrow up 78 | 79 | } else if(event.keyCode === 40) { // arrow down 80 | 81 | } 82 | } 83 | 84 | 85 | // This highlights the passed element. It does this by giving the element the 'selected' class and removing it from all siblings 86 | function show(x) { 87 | let par = x.parentElement.parentElement; 88 | [...par.children].forEach(element => { 89 | if(element.children[0] === x) { 90 | x.classList.add('selected') 91 | } else { 92 | element.children[0].classList.remove('selected') 93 | } 94 | }) 95 | } 96 | 97 | function rotate(x) { 98 | if(x.classList.contains('rotating')) { 99 | x.classList.remove('rotating') 100 | } else { 101 | x.classList.add('rotating') 102 | } 103 | } 104 | 105 | //:::: INFOCARD ::::\\ 106 | 107 | // This variable can be overwritten by different new <>Buffer() classes. 108 | let localBuffer 109 | 110 | // moves in one direction through the stacks 111 | function moveCards(clickedButton, direction) { 112 | let stacks = getCardStacks() 113 | switch(direction) { 114 | case 'right': 115 | if(stacks.right.length !== 0) { 116 | let midCard = stacks.middle[0] 117 | midCard.classList.remove('middle_stack') 118 | midCard.classList.add('left_stack') 119 | // get the bottom right one 120 | let rigCard = stacks.right[0] 121 | rigCard.classList.remove('right_stack') 122 | rigCard.classList.add('middle_stack') 123 | } 124 | 125 | // TODO: Logging is only temporary, use results for actual rendering. 126 | localBuffer.move(1, { 127 | first: epData => { // onFirst 128 | // find index of the middle card 129 | let index = getCardStacks().left.length 130 | updateInfoCard(epData, index) 131 | updateLeftRightButtons() 132 | }, 133 | buffer: (bufferData, pos) => { // onBuffer 134 | updateInfoCard(bufferData, pos) 135 | }, 136 | images: (urls, pos) => { // onImage 137 | updateInfoCardImage(urls, pos) 138 | } 139 | }) 140 | break 141 | case 'left': 142 | if(stacks.left.length !== 0) { 143 | // move the middle one 144 | let midCard = stacks.middle[0] 145 | midCard.classList.remove('middle_stack') 146 | midCard.classList.add('right_stack') 147 | // get the top left one 148 | let lefCard = stacks.left[stacks.left.length-1] 149 | lefCard.classList.remove('left_stack') 150 | lefCard.classList.add('middle_stack') 151 | } 152 | 153 | localBuffer.move(-1, { 154 | first: epData => { // onFirst 155 | // find index of the middle card 156 | let index = getCardStacks().left.length 157 | updateInfoCard(epData, index) 158 | updateLeftRightButtons() 159 | }, 160 | buffer: (bufferData, pos) => { // onBuffer 161 | updateInfoCard(bufferData, pos) 162 | }, 163 | images: (urls, pos) => { // onImage 164 | updateInfoCardImage(urls, pos) 165 | } 166 | }) 167 | break 168 | } 169 | updateLeftRightButtons() 170 | } 171 | 172 | function getCardStacks() { 173 | return { 174 | left: document.getElementsByClassName('left_stack'), 175 | middle: document.getElementsByClassName('middle_stack'), 176 | right: document.getElementsByClassName('right_stack') 177 | } 178 | } 179 | 180 | function updateLeftRightButtons() { 181 | let stacks = getCardStacks() 182 | let leftButton = document.getElementById('stack_left_button') 183 | let rightButton = document.getElementById('stack_right_button') 184 | 185 | // check the left stack 186 | if(stacks.left.length === 0) { 187 | leftButton.style.display = 'none' 188 | } else { 189 | leftButton.style.display = 'flex' 190 | } 191 | 192 | // and now the right one 193 | if(stacks.right.length === 0) { 194 | rightButton.style.display = 'none' 195 | } else { 196 | rightButton.style.display = 'flex' 197 | } 198 | 199 | // update the position of the slider thumb 200 | generateStackSlider() 201 | } 202 | 203 | // Closes the info card if already open. Currently, it is only opened by html onclick events. Opening it with this function could be done in future, possibly to speed up loading. 204 | function triggerInfoCardOverlay() { 205 | let infocard_overlay = document.getElementById('infocard_overlay') 206 | let dark_overlay = document.getElementById('info_overlay') 207 | if(infocard_overlay.style.display === 'none') { 208 | // open it 209 | openedPanel = 'cards' 210 | infocard_overlay.style.display = 'flex' 211 | dark_overlay.classList.add('dark_overlay') 212 | } else { 213 | // close it 214 | openedPanel = null 215 | infocard_overlay.style.display = 'none' 216 | document.getElementById('infocard_stack').innerHTML = '' 217 | dark_overlay.classList.remove('dark_overlay') 218 | 219 | // Here, we could nullize the localBuffer so it is not falsely used by some other instance. When doing so, the whole instance would have to be initiated again when reopening the stacks. Because the user could reopen the same card-stack after closing without opening a different item before, we could instead keep the created instance and only overwrite the localBuffer when the opened item is not the same as before. 220 | } 221 | } 222 | 223 | 224 | function addInfoCard(position, index) { 225 | let stack 226 | switch(position) { 227 | case 'left': 228 | stack = 'left_stack' 229 | break 230 | case 'middle': 231 | stack = 'middle_stack' 232 | break 233 | case 'right': 234 | stack = 'right_stack' 235 | break 236 | } 237 | let infocard_stack = document.getElementById('infocard_stack') 238 | infocard_stack.appendChild(generateInfoCardDummy(stack, index)) 239 | } 240 | 241 | function generateInfoCardDummy(stack, index) { 242 | let infocard = document.createElement('div') 243 | infocard.classList = 'infocard shadow_b '+stack 244 | infocard.id = 'card_'+index 245 | infocard.innerHTML = ` 246 |
      247 |
    • 248 |
      249 | 250 |
      251 |
    • 252 |
    253 |
    254 |
    255 | 256 |
    257 |
    258 | ` 259 | return infocard 260 | } 261 | 262 | function generateInfoCardContent(updates) { 263 | return` 264 |
    265 |
    266 | 267 |
    268 |
    269 |
    270 |
    271 |
    272 |
    273 | 274 | ${updates.ratingPercent}% 275 |
    276 |
    277 |
    278 | ${updates.seasonNumber}x${updates.episodeNumber} ${updates.episodeTitle} 279 |
    280 |
    281 |
    282 | 283 |
    284 |
    285 |
    286 |
    287 |
    288 |
    289 |

    ${updates.description}

    290 |
    291 |
    292 | ` 293 | } 294 | 295 | function generateStackSlider() { 296 | let slider = document.getElementById('indicator_slider') 297 | let stacks = getCardStacks() 298 | let totalSize = stacks.left.length 299 | + stacks.middle.length // will always be 1, this makes it understandable 300 | + stacks.right.length 301 | 302 | // Here, we could check if there are more than one items, but its okay to show a single red bar for now. 303 | // set the width of the thump to match the ratio 304 | let sliderWidth = slider.offsetWidth/totalSize 305 | if(sliderWidth < 15) { 306 | sliderWidth = 15 // fix width to height, so it stays visible 307 | } 308 | 309 | let styler = document.querySelector('[data="indicator"]') 310 | styler.innerHTML = ` 311 | #indicator input::-webkit-slider-thumb { 312 | width: ${sliderWidth}px !important; 313 | } 314 | ` 315 | // set position of the thumb 316 | slider.min = 1; 317 | slider.max = totalSize 318 | slider.value = stacks.left.length+1 319 | } 320 | 321 | function updateInfoCard(itemUpdates, index) { 322 | let stacks = getCardStacks() 323 | debugLog('updating card', index) 324 | 325 | let theStack 326 | 327 | if(index < stacks.left.length) { 328 | theStack = stacks.left[index] 329 | } else if(index == stacks.left.length) { 330 | theStack = stacks.middle[0] 331 | } else { 332 | theStack = stacks.right[index - stacks.left.length-1] 333 | } 334 | 335 | if(theStack !== undefined) { 336 | theStack.innerHTML = generateInfoCardContent(itemUpdates) 337 | } else { 338 | debugLog('!updating card', 'failed, could not find element') 339 | } 340 | } 341 | 342 | function updateInfoCardImage(url, index) { 343 | let stacks = getCardStacks() 344 | debugLog('updating card images', index) 345 | 346 | let theCard 347 | 348 | if(index < stacks.left.length) { 349 | theCard = stacks.left[index] 350 | } else if(index == stacks.left.length) { 351 | theCard = stacks.middle[0] 352 | } else { 353 | theCard = stacks.right[index - stacks.left.length-1] 354 | } 355 | 356 | /** url: 357 | * banner 358 | * poster 359 | * actors[] 360 | */ 361 | if(theCard !== undefined) { 362 | theCard.querySelector('.infocard_banner img').src = url.banner 363 | theCard.querySelector('.infocard_poster img').src = url.poster 364 | } else { 365 | debugLog('!updating card', 'failed, could not find element') 366 | } 367 | } 368 | 369 | 370 | /*::::::::::::::::::::::::::::::::::::::::::::::: SIDE-BAR :::::::::::::::::::::::::::::::::::::::::::::::*/ 371 | 372 | // This object holds the DOM-elements and actions of the sidebar. We need this to generate the frame of the sidebar where content can be added dynamically later. Further comments explain the functioning. 373 | let sideBar = { 374 | element: document.getElementById('side_panel'), 375 | // This variable tells which sidebar is currently open. The possible values are: 376 | // 'none' | 'search' | 'settings' | 'logout' 377 | status: 'none', 378 | // These are the available panels 379 | panels: ['search', 'settings', 'logout'], 380 | // Now these are the panel creators 381 | search: { 382 | create: function() { 383 | let panel = document.createElement('div') 384 | panel.classList.add('panel') 385 | 386 | let search_field = document.createElement('input') 387 | search_field.classList.add('panel_header', 'search', 'fs23', 'fw500', 'white_t', 'black_d_b', 'z4') 388 | search_field.type = 'text' 389 | search_field.onkeydown = function() { 390 | if(event.keyCode === 13) { // ENTER 391 | search(search_field.value) 392 | return false 393 | } 394 | } 395 | 396 | setTimeout(() => { 397 | search_field.focus() 398 | }, 220) 399 | panel.appendChild(search_field) 400 | 401 | let box = document.createElement('div') 402 | box.classList.add('panel_header_box', 'top', 'z3') 403 | panel.appendChild(box) 404 | 405 | let gradient = document.createElement('div') 406 | gradient.classList.add('panel_header_gradient', 'top_p', 'z3') 407 | panel.appendChild(gradient) 408 | 409 | let results = document.createElement('div') 410 | results.classList.add('side_panel_list') 411 | results.id = 'results' 412 | panel.appendChild(results) 413 | 414 | return panel 415 | }, 416 | open: function() { 417 | this.parent.appendChild(this.create()) 418 | } 419 | }, 420 | settings: { 421 | create: function() { 422 | let panel = document.createElement('div') 423 | panel.classList.add('panel') 424 | 425 | let headText = document.createElement('h2') 426 | headText.classList.add('panel_header', 'fs23', 'fw500', 'white_t', 'z4') 427 | headText.innerText = 'Settings' 428 | panel.appendChild(headText) 429 | 430 | let box = document.createElement('div') 431 | box.classList.add('panel_header_box', 'top', 'z3') 432 | panel.appendChild(box) 433 | 434 | let gradient = document.createElement('div') 435 | gradient.classList.add('panel_header_gradient', 'top_p', 'z3') 436 | panel.appendChild(gradient) 437 | 438 | let setting_list = document.createElement('div') 439 | setting_list.classList.add('side_panel_list', 'animation_slide_right') 440 | 441 | let settings = getSettings('app') 442 | 443 | let settingsArray = objectToArray(settings) 444 | 445 | delayFunction((index, arr) => { 446 | let s = arr[index].name 447 | let settingBox = addSetting(settings[s], s) 448 | setting_list.appendChild(settingBox) 449 | }, 150, getObjectLength(settings), settingsArray, 2) 450 | 451 | let relaunch_box = document.createElement('div') 452 | relaunch_box.id = 'relaunch_box' 453 | relaunch_box.innerHTML = `

    Some settings require a

    ` // rest is added below 454 | relaunch_box.classList.add('black_d_b', 'shadow_h', 'bottom', 'z4') 455 | relaunch_box.style.visibility = 'hidden' 456 | 457 | let relaunch_button = document.createElement('div') 458 | relaunch_button.innerText = 'relaunch' 459 | relaunch_button.classList.add('btn', 'red_d_b', 'white_t') 460 | relaunch_button.onclick = function() { 461 | relaunchApp() 462 | } 463 | relaunch_box.appendChild(relaunch_button) 464 | 465 | panel.appendChild(setting_list) 466 | panel.appendChild(relaunch_box) 467 | return panel 468 | }, 469 | open: function() { 470 | this.parent.appendChild(this.create()) 471 | } 472 | }, 473 | logout: { 474 | create: function() { 475 | let panel = document.createElement('div') 476 | panel.classList.add('panel') 477 | 478 | let logout_button = document.createElement('button') 479 | logout_button.classList.add('logout_btn', 'fs18', 'white_t', 'black_d_b') 480 | logout_button.innerText = 'Logout' 481 | logout_button.onclick = function() { 482 | signout() 483 | return false 484 | } 485 | panel.appendChild(logout_button) 486 | 487 | let logout_text = document.createElement('div') 488 | logout_text.style = 'text-align:center;' 489 | logout_text.innerHTML = '

    Oh uh!
    You really want to do this?

    ' 490 | panel.appendChild(logout_text) 491 | return panel 492 | }, 493 | open: function() { 494 | this.parent.appendChild(this.create()) 495 | } 496 | }, 497 | // Removes the panel contents from the sidebar 498 | removeAll: function() { 499 | let panels = this.element.getElementsByClassName('panel') 500 | while(panels[0]) { 501 | panels[0].parentNode.removeChild(panels[0]) 502 | } 503 | }, 504 | // This helper initializes the available panels by providing the sidebar element as a parent. The method is called right after the creation of this object. 505 | init: function() { 506 | this.panels.forEach(panel => { 507 | this[panel].parent = this.element 508 | }) 509 | delete this.init 510 | return this 511 | } 512 | }.init() 513 | 514 | 515 | // Opens and closes the given panel 516 | function triggerSidePanel(panelName) { 517 | // Checking if panel is available. This will not be accessible by the user directly, so we could live without the check but for possible future changes it's safer to have and not wonder about weird errors 518 | if(!sideBar.panels.includes(panelName)) { 519 | throw 'panel not available' 520 | } 521 | 522 | let overlay = document.getElementById('overlay') 523 | let side_buttons = document.getElementById('side_buttons') 524 | let side_panel = document.getElementById('side_panel') 525 | 526 | if(sideBar.status == 'none') { 527 | sideBar.status = panelName 528 | openedPanel = 'sidebar' 529 | 530 | // fading out the background 531 | overlay.classList.add('show') 532 | 533 | // now showing the settings panel 534 | side_panel.classList.remove('side_panel_animate_out') 535 | side_panel.classList.add('side_panel_animate_in') 536 | side_buttons.classList.remove('side_buttons_animate_out') 537 | side_buttons.classList.add('side_buttons_animate_in') 538 | 539 | sideBar[panelName].open() 540 | } 541 | 542 | // When the panel-button of the currently opened panel was clicked, the whole sidebar will close 543 | else if(sideBar.status == panelName) { 544 | if(sideBar.status == 'search') { 545 | removeSearchResults() 546 | } 547 | sideBar.status = 'none' 548 | openedPanel = null 549 | 550 | // removing the settings panel 551 | side_panel.classList.remove('side_panel_animate_in') 552 | side_panel.classList.add('side_panel_animate_out') 553 | side_buttons.classList.remove('side_buttons_animate_in') 554 | side_buttons.classList.add('side_buttons_animate_out') 555 | 556 | // fading in the background 557 | overlay.style.display = 'block' 558 | overlay.classList.remove('show') 559 | // the timeout makes a fadeout animation possible 560 | setTimeout(() => { 561 | overlay.style.display = 'none' 562 | sideBar.removeAll() 563 | }, 220) 564 | 565 | // re-highlight the search button 566 | show(document.getElementById('search_button_side')) 567 | } 568 | 569 | // When another button than the currently open panel was clicked, the sidebar stays open and changes it's content 570 | else { 571 | sideBar.status = panelName 572 | sideBar.removeAll() 573 | sideBar[panelName].open() 574 | } 575 | } 576 | 577 | // These functions are called by onclicks in the HTML 578 | function openSearch() { 579 | triggerSidePanel('search') 580 | } 581 | 582 | function openSettings() { 583 | triggerSidePanel('settings') 584 | } 585 | 586 | function openLogout() { 587 | triggerSidePanel('logout') 588 | } 589 | 590 | function closeSidePanel() { 591 | try { 592 | triggerSidePanel(sideBar.status) 593 | } catch(err) { 594 | debugLog('error', err) 595 | } 596 | } 597 | 598 | 599 | /*::::::::::::::::::::::::::::::::::::::::::::::: SETTINGS PANEL :::::::::::::::::::::::::::::::::::::::::::::::*/ 600 | let wantsRelaunch = [] 601 | 602 | // This adds a setting box to the sidepanel 603 | function addSetting(setting, name) { 604 | let setting_area = document.createElement('div') 605 | setting_area.classList.add('setting_holder') 606 | 607 | let setting_title = document.createElement('h3') 608 | setting_title.classList.add('fs18', 'fw500', 'tu', 'tOverflow') 609 | setting_title.innerText = name 610 | 611 | let settingOld = setting.status 612 | 613 | function alertRequiredReload(settingNew) { 614 | let relaunch_box = document.getElementById('relaunch_box') 615 | let setting_list = document.getElementById('side_panel') 616 | let panel = setting_list.children[0] 617 | 618 | if(settingNew !== settingOld) { 619 | wantsRelaunch.push(name) 620 | relaunch_box.style = 'visiblity:visible;' 621 | relaunch_box.classList.remove('animation_fade_out') 622 | relaunch_box.classList.add('animation_slide_up') 623 | panel.children[3].classList.add('relaunch') 624 | let pos = panel.scrollTop 625 | panel.scrollTop = pos+200 626 | } else { 627 | wantsRelaunch = wantsRelaunch.filter(item => item !== name) 628 | if(wantsRelaunch.length === 0) { 629 | relaunch_box.classList.remove('animation_slide_up') 630 | relaunch_box.classList.add('animation_fade_out') 631 | panel.children[3].classList.remove('relaunch') 632 | } 633 | } 634 | debugLog('relaunch required', wantsRelaunch) 635 | } 636 | 637 | switch(setting.type) { 638 | case 'select': { 639 | classname = 'setting_select' 640 | 641 | for(let o in setting.options) { 642 | let opt = setting.options[o] 643 | 644 | let setting_contain = document.createElement('div') 645 | setting_contain.classList.add('setting_container') 646 | 647 | let preview = document.createElement('div') 648 | preview.classList.add('setting_box') 649 | 650 | let def = document.createElement('div') 651 | def.classList.add('setting_def', 'fs14' , 'white_d_t', 'tu', 'tOverflow') 652 | 653 | if(setting.default == o) { 654 | def.innerText = 'default' 655 | } 656 | 657 | if(setting.status == o) { 658 | preview.classList.add('selected') 659 | } 660 | 661 | preview.onclick = function() { 662 | if(!preview.classList.contains('selected')) { 663 | let par = preview.parentElement.parentElement; 664 | [...par.children].forEach(element => { 665 | if(element.children[0] == preview) { 666 | preview.classList.add('selected') 667 | setSetting('app', name, o) 668 | updateApp() 669 | } else { 670 | element.children[0].classList.remove('selected') 671 | } 672 | }) 673 | } 674 | } 675 | 676 | if(opt.preview) { 677 | def.classList.add('top') 678 | setting_contain.classList.add('wide') 679 | preview.style.backgroundImage = `url('../../assets/previews/${opt.value}')` 680 | } else { 681 | setting_area.style = 'display:flex;justify-content:space-between;' 682 | preview.style.backgroundColor = opt.value 683 | } 684 | 685 | setting_contain.appendChild(preview) 686 | setting_contain.appendChild(def) 687 | setting_area.appendChild(setting_contain) 688 | } 689 | break 690 | } 691 | case 'toggle': { 692 | classname = 'setting_toggle' 693 | let check_no = '' 694 | let check_yes = '' 695 | if(setting.status) { 696 | check_yes = 'checked' 697 | } else { 698 | check_no = 'checked' 699 | } 700 | 701 | let idname = name.split(' ').join('_') 702 | 703 | let toggle_switch = document.createElement('div') 704 | toggle_switch.innerHTML = ` 705 |

    706 | 707 | 708 | 711 | 714 |

    715 | ` 716 | 717 | toggle_switch.onclick = function() { 718 | let radio = document.getElementById(`yes_${idname}`) 719 | alertRequiredReload(radio.checked) 720 | setSetting('app', name, radio.checked) 721 | updateApp() 722 | } 723 | 724 | let def = document.createElement('div') 725 | def.classList.add('setting_def', 'fs14' , 'white_d_t', 'tu') 726 | 727 | def.innerText = 'default: ' 728 | if(setting.default) { 729 | def.innerText += 'on' 730 | } else { 731 | def.innerText += 'off' 732 | } 733 | 734 | setting_area.style = 'display:flex;justify-content:space-between;align-items:center;' 735 | setting_area.appendChild(toggle_switch) 736 | setting_area.appendChild(def) 737 | break 738 | } 739 | case 'range': { 740 | classname = 'setting_range' 741 | 742 | let slider = document.createElement('input') 743 | slider.type = 'range' 744 | slider.min = setting.range[0] / setting.accuracy 745 | slider.max = setting.range[1] / setting.accuracy 746 | slider.value = setting.status / setting.accuracy 747 | slider.style.background = `linear-gradient(to right, var(--accent_color) 0%, var(--accent_color) ${setting.status}%, var(--white_d) ${setting.status}%, var(--white_d) 100%)`; 748 | slider.classList.add('slider') 749 | 750 | slider.oninput = function() { 751 | let value = slider.value * setting.accuracy 752 | slider.style.background = 'linear-gradient(to right, var(--accent_color) 0%, var(--accent_color) '+value +'%, var(--white_d) ' + value + '%, var(--white_d) 100%)' 753 | setSetting('app', name, value) 754 | updateApp() 755 | } 756 | 757 | let def = document.createElement('div') 758 | def.classList.add('setting_def', 'fs14' , 'white_d_t', 'tu') 759 | 760 | def.innerText = 'default: ' + setting.default 761 | 762 | setting_area.appendChild(slider) 763 | setting_area.appendChild(def) 764 | break 765 | } 766 | default: { break } 767 | } 768 | 769 | let box = document.createElement('div') 770 | box.classList.add('panel_box', 'panel_box_container', 'setting') 771 | 772 | box.appendChild(setting_title) 773 | box.appendChild(setting_area) 774 | return box 775 | } 776 | 777 | /*::::::::::::::::::::::::::::::::::::::::::::::: SEARCH-PANEL :::::::::::::::::::::::::::::::::::::::::::::::*/ 778 | let searchHistoryCache = new Cache('searchHistory') 779 | 780 | // This gets fired when the user searches something from the sidebar 781 | async function search(text) { 782 | let requestTime = Date.now() 783 | removeSearchResults() 784 | 785 | if(text == '') { 786 | // empty search submitted 787 | return false 788 | } 789 | 790 | let cacheContent = searchHistoryCache.getKey(text) 791 | if(cacheContent !== undefined) { 792 | // add cached search results 793 | } 794 | 795 | let data = await searchRequestHelper(text).then(res => res) 796 | debugLog('request finished', data.date) 797 | 798 | data.result.forEach(item => { 799 | debugLog('search', `adding result ${item.trakt[item.trakt.type].ids.trakt} (${item.trakt.score})`) 800 | // fallback for unavailable images 801 | let img = url = '../../assets/'+config.client.placeholder.search 802 | 803 | if(item.fanart !== undefined) { 804 | if(item.fanart.hasOwnProperty('tvposter')) { 805 | img = item.fanart.tvposter[0].url 806 | } else if(item.fanart.hasOwnProperty('movieposter')) { 807 | img = item.fanart.movieposter[0].url 808 | } 809 | } 810 | 811 | // render search result 812 | addSearchResult({ 813 | title: item.trakt[item.trakt.type].title, 814 | type: item.trakt.type, 815 | rating: Math.round(item.trakt[item.trakt.type].rating * 10), 816 | img: img, 817 | description: item.trakt[item.trakt.type].overview, 818 | id: item.trakt[item.trakt.type].ids.tmdb 819 | }) 820 | }) 821 | 822 | debugLog('time taken', Date.now()-requestTime+'ms') 823 | } 824 | 825 | // This function generates a html element for one search result and adds it to the sidebar. 826 | function addSearchResult(result) { 827 | let panel = document.getElementById('results') 828 | let panel_box = document.createElement('div') 829 | panel_box.classList.add('panel_box', 'search', 'animation_slide_right') 830 | 831 | let poster_img = document.createElement('img') 832 | poster_img.classList.add('poster') 833 | poster_img.src = result.img 834 | 835 | let panel_box_container = document.createElement('div') 836 | panel_box_container.classList.add('panel_box_container') 837 | 838 | let h3 = document.createElement('h3') 839 | h3.classList.add('fs18', 'tOverflow') 840 | h3.innerText = result.title 841 | 842 | let p = document.createElement('p') 843 | p.classList.add('tOverflow', 'normal') 844 | p.innerText = result.description 845 | 846 | let poster_content = document.createElement('div') 847 | poster_content.classList.add('poster-content') 848 | 849 | let poster_content_left = document.createElement('div') 850 | poster_content_left.classList.add('poster-content-left') 851 | 852 | let heart = document.createElement('img') 853 | heart.src = '../../assets/icons/app/heart.svg' 854 | 855 | let span = document.createElement('span') 856 | span.classList.add('fs16') 857 | span.innerText = result.rating + "%" 858 | 859 | let poster_content_right = document.createElement('div') 860 | poster_content_right.classList.add('poster-content-right') 861 | poster_content_right.append(...createActionButtons(result.id)) 862 | 863 | poster_content_left.appendChild(heart) 864 | poster_content_left.appendChild(span) 865 | 866 | poster_content.appendChild(poster_content_left) 867 | poster_content.appendChild(poster_content_right) 868 | 869 | panel_box_container.appendChild(h3) 870 | panel_box_container.appendChild(p) 871 | panel_box_container.appendChild(poster_content) 872 | 873 | panel_box.appendChild(poster_img) 874 | panel_box.appendChild(panel_box_container) 875 | 876 | panel.appendChild(panel_box) 877 | } 878 | 879 | // Removes all elements from the search panel in the sidebar 880 | function removeSearchResults() { 881 | let panel = document.getElementById('results') 882 | boxes = panel.getElementsByClassName('panel_box search') 883 | while(boxes[0]) { 884 | boxes[0].parentNode.removeChild(boxes[0]) 885 | } 886 | } 887 | 888 | 889 | /*::::::::::::::::::::::::::::::::::::::::::::::: UP-NEXT-TO-WATCH :::::::::::::::::::::::::::::::::::::::::::::::*/ 890 | // This gets fired when the dashboard is loaded 891 | async function generatePosterSection(update) { 892 | let requestTime = Date.now() 893 | 894 | let data = await getUnfinishedProgressList(5, update) 895 | 896 | if(update) { 897 | // clear dashboard 898 | document.querySelector('#dash').innerHTML = ` 899 |
    900 |
      ` 901 | } 902 | 903 | data.forEach((item, index) => { 904 | debugLog('item to add', item.show.show.title) 905 | 906 | let next = item.progress.next_episode 907 | let title = item.show.show.title 908 | let subtitle = `${next.season} x ${next.number+(next.number_abs?' ('+next.number_abs +')':'')} ${next.title}` 909 | 910 | if(index === 0) { 911 | createTitle({ 912 | title: title, 913 | subtitle: subtitle 914 | }) 915 | } 916 | createPoster({ 917 | title: title, 918 | subtitle: subtitle, 919 | rating: next.rating, 920 | id: item.show.show.ids.tvdb, 921 | season: next.season, 922 | matcher: `${item.show.show.ids.trakt}_e_${next.season}_${next.number}` 923 | }) 924 | }) 925 | 926 | debugLog('time taken', Date.now()-requestTime+'ms') 927 | } 928 | 929 | 930 | async function createPoster(itemToAdd) { 931 | let li = document.createElement('li') 932 | li.classList.add('poster', 'poster-dashboard', 'shadow_h') 933 | // This is the most important one, as it will tell the rest of the app what item the poster shows. It will be used to provide information when the user clicks on this item. 934 | li.setAttribute('data_matcher', itemToAdd.matcher) 935 | 936 | li.setAttribute('data_title', itemToAdd.title) 937 | li.setAttribute('data_subtitle', itemToAdd.subtitle) 938 | 939 | li.setAttribute('onmouseover', 'animateText(this, true)') 940 | li.setAttribute('onmouseleave', 'animateText(this, false)') 941 | 942 | li.setAttribute('onclick', 'openInfoCard(this)') 943 | 944 | let poster_content = document.createElement('div') 945 | poster_content.classList.add('poster-content') 946 | 947 | let poster_content_left = document.createElement('div') 948 | poster_content_left.classList.add('poster-content-left', 'fs14', 'white_t', 'fw700') 949 | 950 | let heart = document.createElement('img') 951 | heart.src = '../../assets/icons/app/heart.svg' 952 | 953 | let rate = document.createElement('span') 954 | rate.innerText = `${Math.round(itemToAdd.rating * 10)}%` 955 | 956 | poster_content_left.appendChild(heart) 957 | poster_content_left.appendChild(rate) 958 | 959 | let poster_content_right = document.createElement('div') 960 | poster_content_right.classList.add('poster-content-right') 961 | poster_content_right.append(...createActionButtons(itemToAdd.id)) 962 | 963 | poster_content.appendChild(poster_content_left) 964 | poster_content.appendChild(poster_content_right) 965 | 966 | li.appendChild(poster_content) 967 | 968 | requestAndLoadImage({ 969 | parent: li, 970 | use: 'poster', 971 | type: 'season', 972 | itemId: itemToAdd.id, 973 | reference: itemToAdd.season 974 | }) 975 | 976 | let posters = document.getElementById('posters') 977 | posters.appendChild(li); 978 | } 979 | 980 | function createTitle(itemToAdd) { 981 | let title = document.getElementById('poster_title') 982 | 983 | let h3 = document.createElement('h3') 984 | h3.classList.add('h3', 'red_t', 'tu') 985 | h3.innerText = 'up next to watch' 986 | 987 | let h1 = document.createElement('h1') 988 | h1.classList.add('h1', 'white_t', 'tu', 'tOverflow') 989 | h1.innerText = itemToAdd.title 990 | 991 | let h1_2 = document.createElement('h1') 992 | h1_2.classList.add('h1', 'white_d_t', 'tOverflow') 993 | h1_2.innerText = itemToAdd.subtitle 994 | 995 | title.appendChild(h3) 996 | title.appendChild(h1) 997 | title.appendChild(h1_2) 998 | } 999 | 1000 | function animateText(textBox, onenter) { 1001 | let container = document.getElementById('poster_title') 1002 | let container_title = container.children[1] 1003 | let container_subtitle = container.children[2] 1004 | 1005 | let title = textBox.getAttribute('data_title') 1006 | let subtitle = textBox.getAttribute('data_subtitle') 1007 | 1008 | if(title.toLowerCase() !== container_title.innerText.toLowerCase()) { 1009 | if(onenter) { 1010 | toggleAnimation(container_title, 'animation_slide_up', title) 1011 | toggleAnimation(container_subtitle, 'animation_slide_up', subtitle) 1012 | } 1013 | } 1014 | 1015 | let poster = document.getElementById('posters').firstChild 1016 | let poster_title = poster.getAttribute('data_title') 1017 | let poster_subtitle = poster.getAttribute('data_subtitle') 1018 | 1019 | if(poster_title.toLowerCase() !== container_title.innerText.toLowerCase()) { 1020 | if(!onenter) { 1021 | toggleAnimation(container_title, 'animation_slide_up', poster_title) 1022 | toggleAnimation(container_subtitle, 'animation_slide_up', poster_subtitle) 1023 | } 1024 | } 1025 | } 1026 | 1027 | function toggleAnimation(x, y, z) { 1028 | x.classList.remove(y) 1029 | void x.offsetWidth 1030 | x.innerText = z 1031 | x.classList.add(y) 1032 | } 1033 | 1034 | function openInfoCard(poster) { 1035 | // __[season]_[episode] 1036 | let matcher = poster.getAttribute('data_matcher') 1037 | debugLog('info card', matcher) 1038 | matcher = matcher.split('_') 1039 | 1040 | let showId = matcher[0] 1041 | 1042 | switch(matcher[1]) { 1043 | case 'e': { // episode 1044 | let seasonNum = matcher[2] 1045 | let episodeNum = matcher[3] 1046 | 1047 | let onCallbacks = { 1048 | size: epPosition => { 1049 | // remove possibly existing dummies that were used as a loading indicator 1050 | document.getElementById('infocard_stack').innerHTML = '' 1051 | 1052 | let leftStackSize = epPosition.current - 1 1053 | let rightStackSize = epPosition.total - epPosition.current 1054 | 1055 | let i = 0 1056 | 1057 | for(i; i { // onFirst 1070 | // find index of the middle card 1071 | let index = getCardStacks().left.length 1072 | updateInfoCard(epData, index) 1073 | updateLeftRightButtons() 1074 | }, 1075 | buffer: (bufferData, pos) => { // onBuffer 1076 | updateInfoCard(bufferData, pos) 1077 | }, 1078 | images: (urls, pos) => { // onImage 1079 | updateInfoCardImage(urls, pos) 1080 | } 1081 | } 1082 | 1083 | if(localBuffer instanceof showBuffer && localBuffer.id == showId) { 1084 | localBuffer.openAt(seasonNum, episodeNum, onCallbacks) 1085 | } else { 1086 | localBuffer = new showBuffer(showId) 1087 | localBuffer.initAt(seasonNum, episodeNum, onCallbacks) 1088 | } 1089 | 1090 | break 1091 | } 1092 | } 1093 | 1094 | triggerInfoCardOverlay() 1095 | } 1096 | 1097 | /*::::::::::::::::::::::::::::::::::::::::::::::: RPC :::::::::::::::::::::::::::::::::::::::::::::::*/ 1098 | async function updateRpc() { 1099 | if(config.client.settings.app['discord rpc'].status) { 1100 | let settings = await createRpcContent() 1101 | let stateArray = config.client.rpc.states 1102 | settings.state = pick(stateArray) 1103 | rpc.update(settings) 1104 | setInterval(() => { 1105 | settings.state = pick(stateArray) 1106 | rpc.update(settings) 1107 | }, 60e3) 1108 | } 1109 | } 1110 | 1111 | async function createRpcContent() { 1112 | let stats = await getUserStats() // from request module 1113 | return { 1114 | time: { 1115 | movies: stats.movies.minutes, 1116 | shows: stats.episodes.minutes 1117 | } 1118 | } 1119 | } 1120 | 1121 | /*::::::::::::::::::::::::::::::::::::::::::::::: ACTION BUTTONS :::::::::::::::::::::::::::::::::::::::::::::::*/ 1122 | function createActionButtons(item) { 1123 | let playNow = document.createElement('div') 1124 | playNow.classList.add('action_btn', 'play') 1125 | playNow.innerHTML = '' 1126 | playNow.setAttribute('onclick', `playNow(${item})`) 1127 | 1128 | let addToList = document.createElement('div') 1129 | addToList.classList.add('action_btn', 'list') 1130 | addToList.innerHTML = '' 1131 | addToList.setAttribute('onclick', `addToWatchlist(${item})`) 1132 | 1133 | let addToHistory = document.createElement('div') 1134 | addToHistory.classList.add('action_btn', 'history') 1135 | addToHistory.innerHTML = '' 1136 | addToHistory.setAttribute('onclick', `addToHistory(${item})`) 1137 | 1138 | return [playNow, addToList, addToHistory]; 1139 | } 1140 | 1141 | function playNow(item) { 1142 | alert('playing now!') 1143 | } 1144 | 1145 | function addToHistory(item) { 1146 | alert('added to history!') 1147 | } 1148 | 1149 | function addToWatchlist(item) { 1150 | alert('added to watchlist!') 1151 | } 1152 | -------------------------------------------------------------------------------- /pages/dashboard/style.css: -------------------------------------------------------------------------------- 1 | /*::::::::::::::::::::::::::::::::::::::::::::::: MAIN-COMPONENTS :::::::::::::::::::::::::::::::::::::::::::::::*/ 2 | :root { 3 | --side_panel_width: 450px; 4 | --panel_padding: 40px; 5 | --search_result_height: 205px; 6 | } 7 | 8 | .wrapper.dashboard { 9 | height: 100vh; 10 | width: 100vw; 11 | } 12 | 13 | .dashboard { 14 | margin: auto; 15 | min-width: 716px; 16 | } 17 | /*:::::::::::::::::::::::::::::::::::::::::::::::: ACTION-BUTTTONS :::::::::::::::::::::::::::::::::::::::::::::::*/ 18 | .action_btn { 19 | vertical-align: middle; 20 | padding: 8px; 21 | position: relative; 22 | cursor: pointer; 23 | margin: 3px; 24 | border-radius: 5px; 25 | } 26 | 27 | .action_btn, 28 | .action_btn > img { 29 | display: inline-block; 30 | width: 16px; 31 | height: 19px; 32 | } 33 | 34 | .action_btn:first-child { 35 | margin-left: 0; 36 | } 37 | .action_btn:last-child { 38 | margin-right: 0; 39 | } 40 | 41 | .action_btn::before { 42 | position: absolute; 43 | transform: translateY(-15%) translateX(calc(-100% - 7px)); 44 | opacity: 0; 45 | visibility: hidden; 46 | padding: 3px 25px; 47 | font-size: 14px; 48 | white-space: nowrap; 49 | font-weight: 200; 50 | color: var(--white); 51 | border-radius: 3px 0 0 3px; 52 | transition: all 200ms ease; 53 | z-index: 1; 54 | } 55 | 56 | .action_btn:hover::before { 57 | opacity: 1; 58 | visibility: visible; 59 | } 60 | 61 | .action_btn.play { 62 | background-color: #C61017; 63 | } 64 | .action_btn.play::before { 65 | content: 'Play Now?'; 66 | background-color: rgb(153, 13, 17); 67 | } 68 | 69 | .action_btn.list { 70 | background-color: #2B678F; 71 | } 72 | .action_btn.list:before { 73 | content: 'Add to watchlist?'; 74 | background-color: rgb(25, 61, 85); 75 | } 76 | 77 | .action_btn.history { 78 | background-color: #8E44AD; 79 | } 80 | .action_btn.history:before { 81 | content: 'Add to history?'; 82 | background-color: rgb(106, 51, 129); 83 | } 84 | /*:::::::::::::::::::::::::::::::::::::::::::::::: CARDS :::::::::::::::::::::::::::::::::::::::::::::::*/ 85 | #infocard_overlay { 86 | width: 100%; 87 | height: 100%; 88 | position: absolute; 89 | z-index: 7; 90 | display: flex; 91 | justify-content: center; 92 | align-items: center; 93 | } 94 | 95 | #infocard_stack { 96 | width: 70%; 97 | height: 70%; 98 | } 99 | 100 | .infocard { 101 | position: absolute; 102 | width: 70%; 103 | height: 70%; 104 | border-radius: 8px; 105 | background-color: var(--black); 106 | transition: ease 0.5s; 107 | } 108 | 109 | .left_stack { 110 | transform: translateX(-110%); 111 | opacity: 0; 112 | } 113 | 114 | .right_stack { 115 | transform: translateX(110%); 116 | opacity: 0; 117 | } 118 | 119 | .cardcontent { 120 | width: 100%; 121 | height: 100%; 122 | overflow-y: scroll; 123 | } 124 | 125 | .cardcontent::-webkit-scrollbar { 126 | display: none; 127 | } 128 | 129 | .infocard .banner { 130 | max-height: 200px; 131 | overflow: hidden; 132 | } 133 | 134 | .infocard .banner img { 135 | width: 100%; 136 | border-radius: 8px 8px 0 0; 137 | } 138 | 139 | .infosection { 140 | padding: 20px; 141 | width: calc(100% - 40px); 142 | z-index: 1; 143 | } 144 | 145 | .infocard .btns { 146 | display: inline-flex; 147 | position: absolute; 148 | margin: 0; 149 | right: -12px; 150 | top: -12px; 151 | } 152 | 153 | .infocard .btns li { 154 | padding: 5px; 155 | } 156 | 157 | .infocard .btns .btn { 158 | margin: 0; 159 | } 160 | 161 | .infocard .btn.icon { 162 | padding: 5px; 163 | } 164 | 165 | .infocard .btn.icon img { 166 | width: 18px; 167 | height: 18px; 168 | } 169 | 170 | #indicator { 171 | position: absolute; 172 | display: flex; 173 | justify-content: center; 174 | bottom: 4%; 175 | width: 100%; 176 | } 177 | 178 | #indicator input { 179 | -webkit-appearance: none; 180 | width: 35%; 181 | height: 3px; /* has to be odd! */ 182 | border-radius: 3px; 183 | background: var(--black); 184 | outline: none; 185 | -webkit-transition: .2s; 186 | transition: opacity .2s; 187 | pointer-events: none; 188 | } 189 | 190 | #indicator input::-webkit-slider-thumb { 191 | -webkit-appearance: none; 192 | width: 30px; 193 | height: 5px; 194 | border-radius: 5px; 195 | background: var(--accent_color); 196 | } 197 | 198 | 199 | 200 | 201 | 202 | 203 | 204 | 205 | 206 | /*----------------- NEW CARDS----------------*/ 207 | .infocard_child { 208 | position: relative; 209 | width: 100%; 210 | height: 100%; 211 | border-radius: 4px; 212 | overflow-y: auto; 213 | margin: auto; 214 | } 215 | 216 | .infocard_child::-webkit-scrollbar { 217 | display: none; 218 | } 219 | 220 | .infocard_padding { 221 | padding: 10px 20px 10px 250px; 222 | } 223 | 224 | .infocard_banner { 225 | height: 200px; 226 | width: 100%; 227 | position: relative; 228 | } 229 | .infocard_banner img { 230 | width: 100%; 231 | height: 100%; 232 | object-fit: cover; 233 | filter: grayscale(10%); 234 | } 235 | 236 | #infocard_close { 237 | position: absolute; 238 | right: 12px; 239 | top: 12px; 240 | width: 18px; /*visually more appealing that the actual 19px*/ 241 | line-height: 0; 242 | padding: 5px; 243 | border-radius: 5px; 244 | display: inline-block; 245 | text-align: center; 246 | vertical-align: middle; 247 | cursor: pointer; 248 | transition: opacity 200ms ease; 249 | } 250 | 251 | #infocard_close:hover { 252 | opacity: 0.8; 253 | } 254 | 255 | #infocard_close img { 256 | filter: invert(100%); 257 | width: 16px; 258 | height: 19px; 259 | } 260 | 261 | 262 | .infocard_titles { 263 | height: 55px; 264 | } 265 | .infocard_titles * { 266 | display: inline-block; 267 | vertical-align: middle; 268 | } 269 | 270 | .infocard_titles .vertical_border { 271 | margin: 0 20px; 272 | } 273 | 274 | .infocard_titles .rating { 275 | line-height: 24px; 276 | width: 40px; 277 | height: 55px; 278 | text-align: center; 279 | white-space: normal; 280 | } 281 | .infocard_titles .rating > img { 282 | width: 30px; 283 | height: auto; 284 | } 285 | 286 | .infocard_description { 287 | opacity: 0.8; 288 | margin: 0 0 0 2px; 289 | } 290 | 291 | .infocard_poster { 292 | position: absolute; 293 | transform: translateX(30px) translateY(-180px); 294 | width: 190px; 295 | } 296 | .infocard_poster > img { 297 | width: 100%; 298 | height: 250px; 299 | object-fit: cover; 300 | border-radius: 4px; 301 | } 302 | /*:::::::::::::::::::::::::::::::::::::::::::::::: UP-NEXT-TO-WATCH :::::::::::::::::::::::::::::::::::::::::::::::*/ 303 | .titles { 304 | margin-left: 20px; 305 | transform: translateX(236px) translateY(10px); 306 | height: 0; 307 | overflow: visible; 308 | } 309 | 310 | .titles h1, 311 | .titles h3 { 312 | margin: 0; 313 | line-height: 36px; 314 | max-width: 625px; 315 | } 316 | 317 | .titles h1:last-child { 318 | line-height: 39px; 319 | } 320 | 321 | .poster-dashboard:nth-child(n+6) { 322 | display: none; 323 | } 324 | .poster-dashboard:first-child { 325 | width: 236px; 326 | height: 337px; 327 | } 328 | .poster-dashboard:last-child { 329 | margin-right: 0; 330 | } 331 | .poster-dashboard::before { 332 | content: ''; 333 | display: block; 334 | position: absolute; 335 | border-radius: 0 0 5px 5px; 336 | height: 0%; 337 | width: 100%; 338 | bottom: 0; 339 | transition: height 400ms ease; 340 | background-image: linear-gradient( 341 | to bottom, 342 | rgba(255, 255, 255, 0) 0, #000000e0 80% 343 | ); 344 | } 345 | .poster-dashboard:hover::before { 346 | height: 60%; 347 | } 348 | 349 | .poster-content { 350 | position: absolute; 351 | bottom: 0; 352 | display: flex; 353 | justify-content: space-between; 354 | padding: 10px 12px; 355 | opacity: 0; 356 | width: calc(100% - 24px); 357 | flex-direction: column; 358 | align-items: center; 359 | transition: all 400ms ease; 360 | } 361 | .poster-dashboard:first-child > .poster-content { 362 | flex-direction: row; 363 | } 364 | .poster-dashboard:hover > .poster-content { 365 | opacity: 1; 366 | } 367 | 368 | .poster-content-left, 369 | .poster-content-right { 370 | display: inline-block; 371 | vertical-align: middle; 372 | } 373 | .poster-content-left img, 374 | .poster-content-left span { 375 | vertical-align: middle; 376 | } 377 | .poster-content-right { 378 | cursor: pointer; 379 | letter-spacing: 0.5px; 380 | } 381 | .poster-content-left img { 382 | width: 25px; 383 | height: auto; 384 | margin-right: 4px; 385 | } 386 | 387 | .dark_overlay { 388 | display: block !important; 389 | animation: blend_in 0.15s !important; 390 | animation-fill-mode: forwards !important; 391 | } 392 | 393 | #dash_overlay { 394 | top: 0; 395 | left: 0; 396 | height: 100vh; 397 | width: 100vw; 398 | display: none; 399 | 400 | position: absolute; 401 | overflow: hidden; 402 | z-index: 2; 403 | background-color: rgb(20, 20, 20); 404 | 405 | opacity: 0.75; 406 | backdrop-filter: blur(8px); 407 | animation: blend_out 0.15s; 408 | animation-fill-mode: forwards; 409 | } 410 | 411 | #info_overlay { 412 | top: 0; 413 | left: 0; 414 | height: 100vh; 415 | width: 100vw; 416 | display: none; 417 | 418 | position: absolute; 419 | overflow: hidden; 420 | z-index: 6; 421 | background-color: rgb(20, 20, 20); 422 | 423 | opacity: 0.75; 424 | backdrop-filter: blur(8px); 425 | animation: blend_out 0.15s; 426 | animation-fill-mode: forwards; 427 | } 428 | 429 | /*::::::::::::::::::::::::::::::::::::::::::::::: SIDE-PANEL :::::::::::::::::::::::::::::::::::::::::::::::*/ 430 | .side_panel { 431 | display: block; 432 | position: absolute; 433 | height: 100vh; 434 | width: var(--side_panel_width); 435 | background: var(--black); 436 | color: var(--white); 437 | right: calc(-1 * var(--side_panel_width)); 438 | } 439 | 440 | .side_panel .relaunch { 441 | margin-bottom: 150px; 442 | } 443 | 444 | .panel { 445 | padding: var(--side_dist) var(--panel_padding); 446 | overflow: hidden auto; 447 | height: calc(100vh - var(--side_dist)); 448 | scroll-behavior: smooth; 449 | } 450 | 451 | .panel_header, 452 | .panel_header_box, 453 | .panel_header_gradient { 454 | position: absolute; 455 | right: 0; 456 | width: var(--side_panel_width); 457 | } 458 | 459 | .panel_header { 460 | margin: 0; 461 | left: 0; 462 | padding: 4px var(--panel_padding); 463 | width: calc( 464 | var( --side_panel_width) - (2*(var(--panel_padding))) 465 | ); 466 | } 467 | 468 | .panel_header_box { 469 | height: calc(44px + var(--side_dist) + 16px); 470 | background-color: var(--black); 471 | } 472 | 473 | .panel_header_gradient { 474 | margin-top: calc(44px + 12px); 475 | height: 16px; 476 | background-image: linear-gradient( 477 | to bottom, 478 | var(--black), rgba(0,0,0,0) 479 | ); 480 | } 481 | 482 | .side_panel_list { 483 | width: calc(var(--side_panel_width) - (2*(var(--panel_padding)))); 484 | margin-top: calc(44px + 10px + 16px); 485 | } 486 | 487 | .panel_box { 488 | border-radius: 5px; 489 | background-color: var(--black_d); 490 | margin-bottom: 15px; 491 | transition: all 200ms ease; 492 | } 493 | 494 | .panel_box_container { 495 | padding: 12px; 496 | position: relative; 497 | } 498 | 499 | .panel_box_container > h3 { 500 | margin: 0; 501 | } 502 | 503 | .side_panel_animate_out { 504 | right: calc(-1 * var(--side_panel_width)); 505 | animation: move_out_right 0.3s !important; 506 | } 507 | 508 | .side_panel_animate_in { 509 | right: 0px !important; 510 | animation: move_in_right 0.3s !important; 511 | } 512 | 513 | .side_buttons_animate_out { 514 | right: var(--side_dist); 515 | animation: move_out_right_buttons 0.3s !important; 516 | } 517 | 518 | .side_buttons_animate_in { 519 | right: calc(var(--side_panel_width) - 22px) !important; 520 | animation: move_in_right_buttons 0.3s !important; 521 | } 522 | 523 | @keyframes move_in_right { 524 | 0% {opacity: 0;right: calc(-1 * var(--side_panel_width))} 525 | 66% {opacity: 1} 526 | 100% {opacity: 1;right: 0px} 527 | } 528 | 529 | @keyframes move_out_right { 530 | 0% {right: 0px;opacity: 1} 531 | 33% {opacity: 1} 532 | 100% {right: calc(-1 * var(--side_panel_width));opacity: 0} 533 | } 534 | 535 | @keyframes move_in_right_buttons { 536 | 0% {right: var(--side_dist)} 537 | 100% {right: calc(var(--side_panel_width) - 22px)} 538 | } 539 | 540 | @keyframes move_out_right_buttons { 541 | 0% {right: calc(var(--side_panel_width) - 22px)} 542 | 100% {right: var(--side_dist)} 543 | } 544 | /*::::::::::::::::::::::: SEARCH-COMPONENTS :::::::::::::::::::::::*/ 545 | .panel_header.search { 546 | border: none; 547 | border-radius: 0 5px 5px 0; 548 | height: 44px; 549 | left: 0; 550 | padding: 0 20px 0 var(--panel_padding); 551 | width: calc( 552 | var(--side_panel_width) - (2*var(--panel_padding)) - 20px 553 | ); 554 | } 555 | 556 | .panel_box.search { 557 | cursor: pointer; 558 | } 559 | .panel_box.search:hover { 560 | box-shadow: 0px 0px 34px rgba(0, 0, 0, 0.3); 561 | } 562 | 563 | .panel_box.search .poster { 564 | margin: 0; 565 | width: 40%; 566 | border-radius: 5px 0 0 5px; 567 | height: var(--search_result_height); 568 | } 569 | 570 | .panel_box.search .poster, 571 | .panel_box.search .panel_box_container { 572 | display: inline-block; 573 | vertical-align: middle; 574 | } 575 | 576 | .panel_box.search .panel_box_container { 577 | width: calc(60% - 24px); 578 | height: calc(var(--search_result_height) - 24px); 579 | } 580 | 581 | .panel_box_container > p { 582 | font-weight: 300; 583 | font-size: 13px; 584 | opacity: .60; 585 | display: -webkit-box; 586 | line-height: 18px; 587 | -webkit-line-clamp: 6; 588 | -webkit-box-orient: vertical; 589 | margin: 0; 590 | } 591 | 592 | .panel_box.search .poster-content { 593 | opacity: 1; 594 | padding: 0; 595 | flex-direction: row; 596 | bottom: 12px; 597 | } 598 | 599 | .panel_box.search .poster-content-left img { 600 | width: 20px; 601 | } 602 | 603 | #results .action_btn, 604 | #results .action_btn > img { 605 | width: 6px; 606 | } 607 | /*::::::::::::::::::::::: SETTINGS-COMPONENTS :::::::::::::::::::::::*/ 608 | .panel_box.setting > h3 { 609 | margin: 0 0 10px 0; 610 | } 611 | .setting_container { 612 | display: inline-block; 613 | position: relative; 614 | text-align: center; 615 | width: 50px; 616 | vertical-align: top; 617 | margin-right: 10px; 618 | cursor: pointer; 619 | } 620 | .setting_container:last-child { 621 | margin: 0; 622 | } 623 | 624 | .setting_box { 625 | height: 50px; 626 | object-fit: cover; 627 | border: 2px solid var(--white_d); 628 | border-radius: 5px; 629 | } 630 | .setting_box.selected { 631 | border: 2px solid var(--accent_color_d) 632 | } 633 | 634 | .setting_container.wide { 635 | display: block; 636 | width: 100%; 637 | margin-bottom: 10px; 638 | } 639 | .setting_container.wide:last-child { 640 | margin-bottom: 0; 641 | } 642 | .setting_container.wide .setting_def { 643 | position: absolute; 644 | line-height: calc(50px + 4px); 645 | width: 100%; 646 | pointer-events: none; 647 | } 648 | 649 | #relaunch_box { 650 | width: calc(var(--side_panel_width) - (2*var(--side_dist))); 651 | padding: var(--side_dist); 652 | margin-left: calc(-1 * var(--side_dist) - 10px); 653 | } 654 | 655 | #relaunch_box > h3 { 656 | text-align: center; 657 | margin: 0 auto 10px; 658 | } 659 | 660 | #relaunch_box > div { 661 | width: fit-content; 662 | margin: auto; 663 | padding: 5px 20px; 664 | } 665 | /*::::::::::::::::::::::: LOGOUT-COMPONENTS :::::::::::::::::::::::*/ 666 | .logout_btn { 667 | height: 44px; 668 | padding: 0 var(--panel_padding); 669 | border: none; 670 | position: absolute; 671 | top: calc(var(--side_dist) + calc(2 * 44px) + calc(2 * 8px)); 672 | border-radius: 0 44px 44px 0; 673 | cursor: pointer; 674 | left: 0; 675 | } 676 | /*::::::::::::::::::::::::::::::::::::::::::::::: RESPONSIVE :::::::::::::::::::::::::::::::::::::::::::::::*/ 677 | @media screen and (max-width: 952px) { 678 | :root { 679 | --side_panel_width: 380px; 680 | --search_result_height: 165px; 681 | } 682 | 683 | .titles { 684 | transform: translateX(236px) translateY(30px); 685 | } 686 | .titles h1, 687 | .titles h3 { 688 | margin: 0; 689 | line-height: 27px; 690 | max-width: 450px; 691 | } 692 | .titles h1:last-child { 693 | line-height: 34px; 694 | } 695 | 696 | .poster-dashboard:nth-child(4) { 697 | margin-right: 0; 698 | } 699 | .poster-dashboard:nth-child(5) { 700 | display: none; 701 | } 702 | 703 | .side_panel .relaunch { 704 | margin-bottom: 130px; 705 | } 706 | 707 | #relaunch_box { 708 | margin-left: calc(-1 * var(--side_dist) - 20px); 709 | } 710 | 711 | #results .panel_box_container > p { 712 | display: none; 713 | } 714 | } 715 | -------------------------------------------------------------------------------- /pages/loading/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Loading... 8 | 9 | 10 | 23 | 24 | 25 |
      26 | 27 |
      28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /pages/loading/index.js: -------------------------------------------------------------------------------- 1 | // Required for loading! Do not remove the following constants even if they say to be unused! 2 | const trakt = remote.getGlobal('trakt') 3 | const relaunchApp = remote.getGlobal('relaunchApp') 4 | 5 | 6 | let loadingTime = Date.now() 7 | 8 | function alertChecker() { 9 | setTimeout(() => { 10 | let timeTaken = Date.now()-loadingTime 11 | debugLog('loading check', timeTaken) 12 | 13 | if(timeTaken > 12e3) { 14 | debugLog('loading', 'too long') 15 | showAlertBox() 16 | } else { 17 | alertChecker() 18 | } 19 | }, 1e3) 20 | } 21 | 22 | alertChecker() 23 | 24 | 25 | new Promise((resolve, rej) => { 26 | resolve(ipcRenderer.sendSync('loading-screen', 'loaded')) 27 | }).then(async res => { 28 | if(res === 'start') { 29 | debugLog('loading', 'started') 30 | 31 | debugLog('loading', 'activities') 32 | let activities = await newActivitiesAvailable() 33 | 34 | debugLog('loading', 'up next to watch') 35 | let upNext = await getUnfinishedProgressList(5) 36 | 37 | debugLog('loading', 'done') 38 | setTimeout(() => { 39 | // telling the app to move on, we need this to trigger the opening of the dashboard page 40 | ipcRenderer.send('loading-screen', 'done') 41 | }, 33.3) // giving some small extra timeout 42 | } else { 43 | debugLog('error', res, new Error().stack) 44 | } 45 | }) 46 | 47 | 48 | function showAlertBox() { 49 | let alert_box = document.createElement('div') 50 | alert_box.classList.add('alert_box', 'white_t') 51 | alert_box.innerHTML = ` 52 |

      53 | Man, this takes long! 54 |
      55 | Try relaunching or reach out to us here to get help. 56 |

      57 | ` 58 | document.body.appendChild(alert_box) 59 | } 60 | -------------------------------------------------------------------------------- /pages/loading/style.css: -------------------------------------------------------------------------------- 1 | .absolute { 2 | position: absolute; 3 | top: 0; 4 | bottom: 0; 5 | left: 0; 6 | right: 0; 7 | transform: translateY(calc(50% - 120px)); 8 | } 9 | 10 | .logo { 11 | height: 180px; 12 | width: 180px; 13 | margin: auto; 14 | display: block; 15 | } 16 | 17 | .alert_box { 18 | text-align: center; 19 | position: absolute; 20 | width: 100vw; 21 | display: flex; 22 | justify-content: center; 23 | top: 60%; 24 | animation: fly_in_alert 0.6s; 25 | } 26 | 27 | .alert_box span { 28 | cursor: pointer; 29 | } 30 | 31 | @keyframes fly_in_alert { 32 | 0% { 33 | transform: translateY(50px) scale(0.8); 34 | opacity: 0; 35 | } 36 | 65% { 37 | transform: translateY(0) scale(1.1); 38 | opacity: 1; 39 | } 40 | 100% { 41 | transform: scale(1); 42 | } 43 | } -------------------------------------------------------------------------------- /pages/login/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 11 | Traktify | Login 12 | 13 | 14 |
      15 |
      16 | 37 | Buy us a coffee? 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /pages/login/index.js: -------------------------------------------------------------------------------- 1 | function codeToClipboard() { 2 | remote.getGlobal('codeToClipboard')() 3 | } 4 | 5 | function authenticate() { 6 | document.getElementById('sign_in_btn').style.display = 'none' 7 | document.getElementById('sign_in_alert').style.display = 'block' 8 | document.getElementById('get_code_again').style.display = 'block' 9 | remote.getGlobal('authenticate')() 10 | } 11 | -------------------------------------------------------------------------------- /pages/login/style.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --accent_color: #ED1C24; 3 | --accent_color_d: #BD161C; 4 | --background_image: url('../assets/furniture.png'); 5 | --background_opacity : 1; 6 | } 7 | 8 | .wrapper.login { 9 | padding: 0; 10 | min-height: 100vh; 11 | } 12 | 13 | .login { 14 | padding: 100px; 15 | text-align: center; 16 | margin: auto; 17 | border-radius: 17px; 18 | } 19 | 20 | .login h1 { 21 | font-size: 32px; 22 | } 23 | 24 | .logo img { 25 | width: 213px; 26 | height: 213px; 27 | margin-bottom: 35px; 28 | } 29 | .logo div { 30 | font-size: 50px; 31 | line-height: 20px; 32 | } 33 | .logo div span { 34 | font-size: 15px; 35 | letter-spacing: 1.7px; 36 | } 37 | 38 | @media screen and (max-height: 650px) { 39 | .login { 40 | display: flex; 41 | flex-flow: column; 42 | align-items: center; 43 | justify-content: center; 44 | width: -webkit-fill-available; 45 | height: -webkit-fill-available; 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /pages/main.js: -------------------------------------------------------------------------------- 1 | if(!remote.getGlobal('darwin')) { 2 | let dragger = document.getElementById('dragger') 3 | if(dragger !== null) { 4 | dragger.remove() 5 | } 6 | } 7 | 8 | 9 | //:::: GLOBAL FUNCTIONS ::::\\ 10 | 11 | function signout() { 12 | remote.getGlobal('disconnect')() 13 | } 14 | 15 | function openDonate() { 16 | remote.getGlobal('openExternal')('https://buymeacoff.ee/CodingBobby') 17 | } 18 | 19 | // TODO: Reloading should only fetch latest changes without reloading the html 20 | function reload() { 21 | remote.getGlobal('loadDashboard')() 22 | } 23 | 24 | function debugLog(...args) { 25 | remote.getGlobal('debugLog').apply(null, args) 26 | } 27 | 28 | 29 | //:::: HELPERS ::::\\ 30 | 31 | // inserts an element before another one 32 | function insertBefore(element, reference) { 33 | reference.parentNode.insertBefore(element, reference) 34 | } 35 | 36 | // adds multiple css styles to an element, example: 37 | // styles: { display: 'block', backgroundColor: 'red' } 38 | function css(element, styles) { 39 | for(let property in styles) { 40 | // just a security check 41 | if({}.hasOwnProperty.call(styles, property)) { 42 | element.style[property] = styles[property] 43 | } 44 | } 45 | } 46 | 47 | // returns a random item from a given array 48 | function pick(array) { 49 | return array[Math.floor(Math.random() * array.length)] 50 | } 51 | 52 | // works as Array.prototype.length 53 | function getObjectLength(obj) { 54 | let len = 0 55 | for(let item in obj) { 56 | if(obj.hasOwnProperty(item)) { 57 | len++ 58 | } 59 | } 60 | return len 61 | } 62 | 63 | // returns array of object's key-value pairs 64 | function objectToArray(obj) { 65 | let arr = [] 66 | for(let item in obj) { 67 | arr.push({ 68 | name: item, 69 | content: obj[item] 70 | }) 71 | } 72 | return arr 73 | } 74 | 75 | // This handy function allows to delay recursive actions. The taken arguments are explained below 76 | function delayFunction( 77 | callback, // function that contains whatever you want, takes an index and an optional array 78 | delay, // the time to wait between iterations in ms 79 | itemCount, // the maximum count 80 | arrayToPass=[], // optional array you want to process 81 | terminateAtIndex=itemCount, // optional index after which the callback is not delayed anymore, can be used when having many out-of-view items where the delay would stack up otherwise 82 | current=0 // current iteration index, only used by the function itself 83 | ) { 84 | if(itemCount-current > 0) { 85 | callback(current, arrayToPass) 86 | if(current >= terminateAtIndex) { 87 | debugLog('delay', 'terminated') 88 | delay = 0 89 | } 90 | setTimeout(() => { 91 | delayFunction(callback, delay, itemCount, arrayToPass, terminateAtIndex, current+1) 92 | }, delay) 93 | } 94 | } 95 | 96 | function loadImage(parent, src, loadingSrc) { 97 | let loading_img = document.createElement('img') 98 | loading_img.src = '../../assets/'+loadingSrc 99 | 100 | parent.appendChild(loading_img) 101 | 102 | let img = document.createElement('img') 103 | img.src = src 104 | 105 | img.onload = function() { 106 | setTimeout(() => { 107 | parent.removeChild(loading_img) 108 | parent.appendChild(img) 109 | }, 7*33.3) // some extra animation and framerate buffer 110 | } 111 | } 112 | 113 | /** 114 | * @param {object} options 115 | * @param options.parent dom element the image should be appended to 116 | * @param {'poster'} options.use in what type of element the image will be used 117 | * @param {'season'} options.type type the item belongs to 118 | * @param {number} options.itemId tvdb id of the item 119 | * @param {any} options.reference some reference we can use 120 | */ 121 | 122 | async function requestAndLoadImage(options) { 123 | let loading_img = document.createElement('img') 124 | // the actual image, the placeholder gets updated to 125 | let img = document.createElement('img') 126 | 127 | switch(options.use) { 128 | case 'poster': { 129 | loading_img.src = '../../assets/loading_placeholder.gif' 130 | options.parent.appendChild(loading_img) 131 | 132 | switch(options.type) { 133 | case 'season': { 134 | img.src = await getSeasonPoster(options.itemId, options.reference) 135 | 136 | img.onload = function() { 137 | setTimeout(() => { 138 | options.parent.removeChild(loading_img) 139 | options.parent.appendChild(img) 140 | }, 3*33.3) // some extra animation and framerate buffer 141 | } 142 | break 143 | } 144 | } 145 | break 146 | } 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /prototype/traktify.xd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodingBobby/traktify/011405c811e640a60e96d61f1e976dabfb73833d/prototype/traktify.xd --------------------------------------------------------------------------------