Initial Push of Project

This commit is contained in:
PrestonN 2018-03-01 22:48:12 -05:00
parent 9eac7a0a3f
commit d222f3edb4
36 changed files with 9857 additions and 0 deletions

24
.compilerc Normal file
View File

@ -0,0 +1,24 @@
{
"env": {
"development": {
"application/javascript": {
"presets": [
["env", { "targets": { "electron": "1.4" } }],
"react"
],
"plugins": ["transform-async-to-generator"],
"sourceMaps": "inline"
}
},
"production": {
"application/javascript": {
"presets": [
["env", { "targets": { "electron": "1.4" } }],
"react"
],
"plugins": ["transform-async-to-generator"],
"sourceMaps": "none"
}
}
}
}

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules
out
\.idea/
subscriptions\.db
.vscode/
.eslintrc*
*.db
electron-packager/win32-x64/FreeTube-win32-x64/

2729
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

69
package.json Normal file
View File

@ -0,0 +1,69 @@
{
"name": "FreeTube",
"productName": "FreeTube",
"version": "0.1.0",
"description": "An Open Source YouTube app for privacy.",
"main": "src/js/init.js",
"scripts": {
"start": "electron-forge start",
"package": "electron-forge package",
"make": "electron-forge make",
"publish": "electron-forge publish",
"make:darwin": "electron-forge make --platform=darwin",
"make:linux": "electron-forge make --platform=linux",
"make:win32": "electron-forge make --platform=win32"
},
"keywords": [],
"author": {
"name": "PrestonN",
"email": "FreeTubeApp@protonmail.com",
"url": "https://github.com/FreeTubeApp/FreeTube"
},
"license": "GPL-3.0",
"config": {
"forge": {
"make_targets": {
"win32": [
"squirrel"
],
"darwin": [
"zip"
],
"linux": [
"deb",
"rpm"
]
},
"electronPackagerConfig": {
"packageManager": "yarn",
"icon": "./src/icon.icns"
},
"electronWinstallerConfig": {
"name": "freetube",
"iconUrl": "https://raw.githubusercontent.com/FreeTubeApp/FreeTubeApp.github.io/master/images/icon.ico",
"setupIcon": "./src/icon.ico"
},
"electronInstallerDebian": {
"icon": "src/icon.svg"
},
"repository": {
"type": "git",
"url": "https://github.com/FreeTubeApp/FreeTube"
}
}
},
"devDependencies": {
"electron-forge": "^5.1.1",
"electron-prebuilt-compile": "1.8.2",
"electron-winstaller": "^2.6.4"
},
"dependencies": {
"autolinker": "^1.6.2",
"dateformat": "^3.0.3",
"electron-compile": "^6.4.2",
"jquery": "^3.3.1",
"mustache": "^2.3.0",
"nedb": "^1.8.0",
"node-async-loop": "^1.2.2"
}
}

3729
src/icon.icns Normal file

File diff suppressed because it is too large Load Diff

BIN
src/icon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 361 KiB

BIN
src/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

83
src/icon.svg Normal file
View File

@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="222mm"
height="222mm"
viewBox="0 0 222 222"
version="1.1"
id="svg8"
inkscape:version="0.92.2 (5c3e80d, 2017-08-06)"
sodipodi:docname="icon.svg">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="0.35"
inkscape:cx="-100"
inkscape:cy="445.71429"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
showguides="true"
inkscape:guide-bbox="true"
inkscape:pagecheckerboard="false"
inkscape:window-width="1720"
inkscape:window-height="1336"
inkscape:window-x="0"
inkscape:window-y="27"
inkscape:window-maximized="0" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-75)">
<circle
id="path3711"
cx="110.74702"
cy="186.25298"
r="102.43154"
style="fill:#eeeeee;fill-opacity:1;stroke:#f44336;stroke-width:15.02900028;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1;stroke-linecap:round;stroke-linejoin:bevel" />
<path
sodipodi:type="star"
style="fill:#f44336;fill-opacity:1;stroke-width:0.26458332"
id="path4520"
sodipodi:sides="3"
sodipodi:cx="108.25239"
sodipodi:cy="186.70654"
sodipodi:r1="65.785233"
sodipodi:r2="32.892616"
sodipodi:arg1="0"
sodipodi:arg2="1.0471976"
inkscape:flatsided="false"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 174.03762,186.70654 -49.33893,28.48585 -49.338918,28.48584 0,-56.97169 0,-56.97168 49.338928,28.48584 z"
inkscape:transform-center-x="-16.446307"
inkscape:transform-center-y="-1.0179227e-06" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

74
src/index.html Normal file
View File

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<link rel="stylesheet" href="style/main.css">
<link rel="stylesheet" href="style/lightTheme.css">
<link rel="stylesheet" href="style/player.css">
<link rel="stylesheet" href="style/videoList.css">
<link rel="stylesheet" href="style/channel.css">
<link rel="stylesheet" href="style/loading.css">
<link href="https://fonts.googleapis.com/css?family=Roboto" rel="stylesheet">
<script defer src="https://use.fontawesome.com/releases/v5.0.2/js/all.js"></script>
<script src="https://apis.google.com/js/api.js"></script>
<script src="https://www.youtube.com/iframe_api"></script>
<script src="js/settings.js"></script>
<script src="js/layout.js"></script>
<script src="js/videos.js"></script>
<script src="js/subscriptions.js"></script>
<script src="js/channels.js"></script>
<script src="js/savedVideos.js"></script>
<script src="js/history.js"></script>
<script src="js/events.js"></script>
<title>Freetube Player</title>
</head>
<body>
<div id='loading'>
<div class="spinner">
<div class="double-bounce1"></div>
<div class="double-bounce2"></div>
</div>
</div>
<div id='confirmFunction'>
<span id='confirmMessage'>Would you like to perform the function?</span>
<div class='confirmButton' id='confirmYes'>Yes</div>
<div class='confirmButton' id='confirmNo'>No</div>
</div>
<div id='toast'>
<span id='toastMessage'></span>
<i onclick='hideToast()' class="closeToast fas fa-times"></i>
</div>
<div class="topNav">
<span class="logo"><i onclick='toggleSideNavigation()' class="menuButton fas fa-bars"></i>&nbsp;&nbsp;FreeTube <i class="far fa-play-circle"></i></span>
<div class="searchBar">
<input id='jumpToInput' class='jumpToInput' type='text' placeholder="Jump to Video Link / Id" /><i onclick='parseVideoLink()' class="fas fa-search searchButton" style='margin-right: 50px; cursor: pointer;'></i>
<input id='search' class="search" type="text" placeholder="Search">
<i onclick='search()' class="fas fa-search searchButton" style='margin-right: -10px; cursor: pointer'></i>
</div>
</div>
<div id='sideNavDisabled'></div>
<div id="sideNav">
<div class="sideNavContainer">
<ul>
<li onclick='loadSubscriptions()'><i class="fas fa-users"></i>&nbsp;&nbsp;Subscriptions</li>
<li onclick='showMostPopular()'><i class="fas fa-fire"></i>&nbsp;&nbsp;Most Popular</li>
<li onclick='showSavedVideos()'><i class="fas fa-star"></i>&nbsp;&nbsp;Saved</li>
<li onclick='showHistory()'><i class="fas fa-history"></i>&nbsp;&nbsp;History</li>
</ul>
<hr />
<ul>
<li onclick='showSettings()'><i class="fas fa-cog"></i>&nbsp;&nbsp;Settings</li>
<li onclick='showAbout()'><i class="far fa-question-circle"></i>&nbsp;&nbsp;About</li>
</ul>
<hr />
<ul id='subscriptions'>
</ul>
</div>
</div>
<div id="main">
</div>
</body>
</html>

120
src/js/channels.js Normal file
View File

@ -0,0 +1,120 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* File for all functions related specifically for channels.
*/
/*function getChannelThumbnail(channelId, callback) {
let url = '';
let request = gapi.client.youtube.channels.list({
'id': channelId,
'part': 'snippet',
});
request.execute((response) => {
url = response['items'][0]['snippet']['thumbnails']['high']['url'];
callback(url);
});
}*/
/**
* View a channel page, displaying recent uplaods.
*
* @param {string} channelId - The channel ID to go to.
*
* @return {Void}
*/
function goToChannel(channelId) {
event.stopPropagation();
clearMainContainer();
toggleLoading();
// Check if the user is subscribed to the channel. Display different text based on the information
let subscribeText = '';
const checkSubscription = isSubscribed(channelId);
checkSubscription.then((results) => {
if(results === false){
subscribeText = 'SUBSCRIBE';
}
else{
subscribeText = 'UNSUBSCRIBE';
}
});
// Call YouTube API to grab channel information
let request = gapi.client.youtube.channels.list({
part: 'snippet, brandingSettings, statistics',
id: channelId,
});
// Perform API Execution
request.execute((response) => {
// Set variables of extracted information
const brandingSettings = response['items'][0]['brandingSettings'];
const statistics = response['items'][0]['statistics'];
const snippet = response['items'][0]['snippet'];
const channelName = brandingSettings['channel']['title'];
const channelBanner = brandingSettings['image']['bannerImageUrl'];
const channelImage = snippet['thumbnails']['high']['url'];
// Channels normally have links in their channel description. This makes them clickable.
const channelDescription = autolinker.link(brandingSettings['channel']['description']);
// Add commas to sub count to make them more readable.
let subCount = statistics['subscriberCount'].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
// Grab the channelView.html template and fill it in with the above variables.
$.get('templates/channelView.html', (template) => {
mustache.parse(template);
const rendered = mustache.render(template, {
channelName: channelName,
channelImage: channelImage,
channelBanner: channelBanner,
channelId: channelId,
subCount: subCount,
channelDescription: channelDescription,
isSubscribed: subscribeText,
});
// Render the template on to #main
$('#main').html(rendered);
toggleLoading();
});
// Grab the channel's latest upload. API forces a max of 50.
let request = gapi.client.youtube.search.list({
part: 'snippet',
channelId: channelId,
type: 'video',
maxResults: 50,
order: 'date',
});
// Execute API request
request.execute((response) => {
// Display recent uploads to #main
response['items'].forEach((video) => {
displayVideos(video);
});
});
});
}

87
src/js/events.js Normal file
View File

@ -0,0 +1,87 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* File for events within application. Work needs to be done throughout the application
* to use this style more. Please use this style going forward if possible.
*/
/**
* Event when user clicks comment box,
* and wants to show/display comments for the user.
*/
let showComments = function(event) {
let comments = $('#comments');
if (comments.css('display') === 'none') {
comments.attr('loaded', 'true');
let commentsTemplate = $.get('templates/comments.html');
commentsTemplate.done((template) => {
let request = gapi.client.youtube.commentThreads.list({
'videoId': $('#comments').attr('data-video-id'),
'part': 'snippet,replies',
'maxResults': 100,
});
request.execute((data) => {
let comments = [];
let items = data.items;
items.forEach((object) => {
let snippet = object['snippet']['topLevelComment']['snippet'];
let dateString = new Date(snippet.publishedAt);
let publishedDate = dateFormat(dateString, "mmm dS, yyyy");
snippet.publishedAt = publishedDate;
comments.push(snippet);
})
const html = mustache.render(template, {
comments: comments,
});
$('#comments').html(html);
});
});
comments.show();
} else {
comments.hide();
}
};
/**
* Play / Pause the video player upon click.
*/
let playPauseVideo = function(event) {
let el = event.currentTarget;
el.paused ? el.play() : el.pause();
}
/**
* ---------------------------
* Bind click events
* --------------------------
*/
$(document).on('click', '#showComments', showComments);
$(document).on('click', '.videoPlayer', playPauseVideo);
$(document).on('click', '#confirmNo', hideConfirmFunction);

92
src/js/history.js Normal file
View File

@ -0,0 +1,92 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* File used for functions related to video history.
*/
/**
* Add a video to the history database file
*
* @param {string} videoId - The video ID of the video to be saved.
*
* @return {Void}
*/
function addToHistory(videoId){
const data = {
videoId: videoId,
timeWatched: new Date().getTime(),
};
historyDb.insert(data, (err, newDoc) => {});
}
/**
* Remove a video from the history database file
*
* @param {string} videoId - The video ID of the video to be removed.
*
* @return {Void}
*/
function removeFromHistory(videoId){
const data = {videoId: videoId};
historyDb.remove(data, {}, (err, numRemoved) => {});
}
/**
* Show the videos within the history database.
*
* @return {Void}
*/
function showHistory(){
clearMainContainer();
toggleLoading();
console.log('checking history');
let videoList = '';
historyDb.find({}).sort({
timeWatched: -1
}).exec((err, docs) => {
if(docs.length > 49){
// The YouTube API limits the search to 50 videos, so grab 50 most recent.
for (let i = 0; i < 49; i++) {
videoList = videoList + ',' + docs[i]['videoId'];
}
}
else{
docs.forEach((video) => {
videoList = videoList + ',' + video['videoId'];
});
}
let request = gapi.client.youtube.videos.list({
part: 'snippet',
id: videoList,
maxResults: 50,
});
request.execute((response) => {
createVideoListContainer('Watch History:');
response['items'].forEach((video) => {
displayVideos(video, 'history');
});
toggleLoading();
});
});
}

117
src/js/init.js Normal file
View File

@ -0,0 +1,117 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* File used to initializing the application
*/
const {app, BrowserWindow} = require('electron');
const path = require('path');
const url = require('url');
let win;
/**
* initialize the electron application
* 1. create the browser window
* 2. load the index.html
*/
let init = function() {
const Menu = require('electron').Menu;
win = new BrowserWindow({width: 1200, height: 800});
win.loadURL(url.format({
pathname: path.join(__dirname, '../index.html'),
protocol: 'file:',
slashes: true,
}));
if (process.env = 'development') {
//win.webContents.openDevTools();
}
win.on('closed', () => {
win = null;
});
const template = [
{
label: 'Edit',
submenu: [
{role: 'undo'},
{role: 'redo'},
{type: 'separator'},
{role: 'cut'},
{role: 'copy'},
{role: 'paste'},
{role: 'pasteandmatchstyle'},
{role: 'delete'},
{role: 'selectall'}
]
},
{
label: 'View',
submenu: [
{role: 'reload'},
{role: 'forcereload'},
{role: 'toggledevtools'},
{type: 'separator'},
{role: 'resetzoom'},
{role: 'zoomin'},
{role: 'zoomout'},
{type: 'separator'},
{role: 'togglefullscreen'}
]
},
{
role: 'window',
submenu: [
{role: 'minimize'},
{role: 'close'}
]
}
];
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
};
/**
* quit the application
*/
let allWindowsClosed = function() {
app.quit();
};
/**
* on mac, when dock icon is clicked,
* create a new window and launch the editor
*/
let active = function() {
if (win === null) {
init();
}
};
/**
* bind events, ready (initialize),
* window-all-closed (what happens when the windows closed),
* activate
*/
app.on('ready', init);
app.on('window-all-closed', allWindowsClosed);
app.on('activate', active);

294
src/js/layout.js Normal file
View File

@ -0,0 +1,294 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* File for main layout manipulation and general variable configuration.
* There are some functions from other files that will probably need to be moved to here.
*/
// Add general variables. Please put all require statements here.
const Datastore = require('nedb'); // Database logic
window.$ = window.jQuery = require('jquery');
const mustache = require('mustache'); // Templating
const dateFormat = require('dateformat'); // Formating Dates
// Used for finding links within text and making them clickable. Used mostly for video descriptions.
const autolinker = require('autolinker');
const electron = require('electron');
// Used for getting the user's subscriptions. Can probably remove this when that function
// is rewritten.
const asyncLoop = require('node-async-loop');
const shell = electron.shell; // Used to open external links into the user's native browser.
const localDataStorage = electron.remote.app.getPath('userData'); // Grabs the userdata directory based on the user's OS
const clipboard = electron.clipboard;
const fs = require('fs'); // Used to read files. Specifically in the settings page.
let currentTheme = '';
let apiKey;
let dialog = require('electron').remote.dialog; // Used for opening file browser to export / import subscriptions.
let toastTimeout; // Timeout for toast notifications.
// Subscriptions database file
const subDb = new Datastore({
filename: localDataStorage + '/subscriptions.db',
autoload: true
});
// History database file
const historyDb = new Datastore({
filename: localDataStorage + '/videohistory.db',
autoload: true
});
// Saved videos database file
const savedVidsDb = new Datastore({
filename: localDataStorage + '/savedvideos.db',
autoload: true
});
// Settings database file.
const settingsDb = new Datastore({
filename: localDataStorage + 'settings.db',
autoload: true
});
// Grabs the default settings from the settings database file. Makes defaults if
// none are found.
checkDefaultSettings();
// Ppen links externally by default
$(document).on('click', 'a[href^="http"]', (event) =>{
let el = event.currentTarget;
event.preventDefault();
shell.openExternal(el.href);
});
// Open links externally on middle click.
$(document).on('auxclick', 'a[href^="http"]', (event) =>{
let el = event.currentTarget;
event.preventDefault();
shell.openExternal(el.href);
});
$(document).ready(() => {
const searchBar = document.getElementById('search');
const jumpToInput = document.getElementById('jumpToInput');
// Displays the list of subscriptions in the side bar.
displaySubs();
// Allow user to use the 'enter' key to search for a video.
searchBar.onkeypress = (e) => {
if (e.keyCode === 13) {
search();
}
};
// Allow user to use the 'enter' key to open a video link.
jumpToInput.onkeypress = (e) => {
if (e.keyCode === 13) {
parseVideoLink();
}
};
// Display subscriptions upon the app opening up. May allow user to specify.
// Home page in the future.
loadSubscriptions();
});
/**
* Start the YouTube API.
*
* @return {Void}
*/
function start() {
// Initializes the client with the API key and the Translate API.
gapi.client.init({
'apiKey': apiKey,
})
gapi.client.load('youtube', 'v3', () => {
let isLoad = true;
});
}
/**
* Toggle the ability to view the side navigation bar.
*
* @return {Void}
*/
function toggleSideNavigation() {
const sideNav = document.getElementById('sideNav');
const mainContainer = document.getElementById('main');
if (sideNav.style.display === 'none') {
sideNav.style.display = 'inline';
mainContainer.style.marginLeft = '250px';
} else {
sideNav.style.display = 'none';
mainContainer.style.marginLeft = '0px';
}
}
/**
* Clears out the #main container to allow other information to be shown.
*
* @return {Void}
*/
function clearMainContainer() {
const container = document.getElementById('main');
container.innerHTML = '';
hideConfirmFunction();
}
/**
* Show the loading animation before / after a function runs. Also disables / enables input
*
* @return {Void}
*/
function toggleLoading() {
const loading = document.getElementById('loading');
const sideNavDisabled = document.getElementById('sideNavDisabled');
const searchBar = document.getElementById('search');
const goToVideoInput = document.getElementById('jumpToInput');
if (loading.style.display === 'none' || loading.style.display === '') {
loading.style.display = 'inherit';
sideNavDisabled.style.display = 'inherit';
searchBar.disabled = true;
goToVideoInput.disabled = true;
} else {
loading.style.display = 'none';
sideNavDisabled.style.display = 'none';
searchBar.disabled = false;
goToVideoInput.disabled = false;
}
}
/**
* Creates a div container in #main meant to be a container for video lists.
*
* @param {string} headerLabel - The header of the container. Not used for showing video recommendations.
*
* @return {Void}
*/
function createVideoListContainer(headerLabel = '') {
const videoListContainer = document.createElement("div");
videoListContainer.id = 'videoListContainer';
let headerSpacer;
if (headerLabel != '') {
const headerElement = document.createElement("h2");
headerElement.innerHTML = headerLabel;
headerElement.style.marginLeft = '15px';
headerElement.appendChild(document.createElement("hr"));
videoListContainer.appendChild(headerElement);
}
document.getElementById("main").appendChild(videoListContainer);
}
/**
* Displays the about page to #main
*
* @return {Void}
*/
function showAbout(){
// Remove current information and display loading animation
clearMainContainer();
toggleLoading();
// Grab about.html to be used as a template
$.get('templates/about.html', (template) => {
mustache.parse(template);
const rendered = mustache.render(template, {
versionNumber: require('electron').remote.app.getVersion(),
});
// Render to #main and remove loading animation
$('#main').html(rendered);
toggleLoading();
});
}
/**
* Display a toast message in the bottom right corner of the page. Toast will
* automatically disappear after 5 seconds.
*
* @param {string} message - The message to be displayed in the toast.
*
* @return {Void}
*/
function showToast(message){
let toast = document.getElementById('toast');
let toastMessage = document.getElementById('toastMessage');
// If a toast message is already being displayed, this will remove the previous timer that was set.
clearTimeout(toastTimeout);
toastMessage.innerHTML = message;
toast.style.visibility = 'visible';
toast.style.opacity = 0.9;
// Set the timer for the toast to be removed.
toastTimeout = window.setTimeout(hideToast, 5000); // 5 seconds
}
/**
* Hide the toast notification from the page.
*
* @return {Void}
*/
function hideToast(){
let toast = document.getElementById('toast');
toast.style.opacity = 0;
toast.style.visibility = 'hidden';
}
/**
* Displays a confirmation box before performing an action. The action will be performed
* if the user clicks 'yes'.
*
* @param {string} message - The message to be displayed in the confirmation box
* @param {function} performFunction - The function to be performed upon confirmation
* @param {*} parameters - The parameters that will be sent to performFunction
*
* @return {Void}
*/
function confirmFunction(message, performFunction, parameters){
let confirmContainer = document.getElementById('confirmFunction');
let confirmMessage = document.getElementById('confirmMessage');
confirmMessage.innerHTML = message;
confirmContainer.style.visibility = 'visible';
$(document).on('click', '#confirmYes', (event) => {
performFunction(parameters);
hideConfirmFunction();
});
}
/**
* Hides the confirmation box. Happens when the user clicks on 'no'.
*
* @return {Void}
*/
function hideConfirmFunction(){
let confirmContainer = document.getElementById('confirmFunction');
confirmContainer.style.visibility = 'hidden';
}

View File

@ -0,0 +1,29 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
/**
* Video Comment Model
*/
export class comment {
authorDisplayName: string;
authorProfileImageUrl: string;
authorChannelId: string;
textDisplay: string;
publishedAt: string;
}

View File

@ -0,0 +1,33 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
import {comment} from "./comment.model";
/**
* Entire Comment Threads for a Video
*/
export class commentThread {
videoId: ?string;
nextPageToken: ?string;
pageInfo: {
totalResults: number,
resultsPerPage: number
};
items: comment[];
}

155
src/js/savedVideos.js Normal file
View File

@ -0,0 +1,155 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* File used for functions related to saving videos
*/
/**
* Adds a video to the user's saved video database.
*
* @param {string} videoId - The video ID of the video that will be saved.
*
* @return {Void}
*/
function addSavedVideo(videoId){
let data = {
videoId: videoId,
timeSaved: new Date().getTime(),
};
savedVidsDb.insert(data, (err, newDoc) => {
showToast('Video has been saved!');
});
}
/**
* Removes a video from the user's saved video database.
*
* @param {string} {videoId} - The video ID of the video that will be removed.
*
* @return {Void}
*/
function removeSavedVideo(videoId){
savedVidsDb.remove({
videoId: videoId
}, {}, (err, numRemoved) => {
showToast('Video has been removed from saved list.');
});
}
/**
* Toggles the save video button styling and saved / remove a video based on the current status.
*
* @param {string} videoId - The video ID to toggle between.
*
* @return {Void}
*/
function toggleSavedVideo(videoId) {
event.stopPropagation();
const checkIfSaved = videoIsSaved(videoId);
const saveIcon = document.getElementById('saveIcon');
const savedText = document.getElementById('savedText');
checkIfSaved.then((results) => {
if (results === false) {
savedText.innerHTML = 'SAVED';
saveIcon.classList.remove('far');
saveIcon.classList.remove('unsaved');
saveIcon.classList.add('fas');
saveIcon.classList.add('saved');
addSavedVideo(videoId);
} else {
savedText.innerHTML = 'SAVE';
saveIcon.classList.remove('fas');
saveIcon.classList.remove('saved');
saveIcon.classList.add('far');
saveIcon.classList.add('unsaved');
removeSavedVideo(videoId);
}
});
}
/**
* Checks if a video was saved in the user's saved video database
*
* @param {string} videoId - The video ID to check
*
* @return {promise} - A boolean value if the video was found or not.
*/
function videoIsSaved(videoId) {
return new Promise((resolve, reject) => {
savedVidsDb.find({videoId: videoId}, (err, docs) => {
if (jQuery.isEmptyObject(docs)) {
resolve(false);
} else {
resolve(true);
}
});
});
}
/**
* Displays a list of the user's saved videos.
*
* @return {Void}
*/
function showSavedVideos(){
clearMainContainer();
toggleLoading();
console.log('checking saved videos');
let videoList = '';
// Check the database for the list of videos.
savedVidsDb.find({}).sort({
timeSaved: -1
}).exec((err, docs) => {
// The YouTube API requires a max of 50 videos to be shown. Don't show more than 50.
// TODO: Allow the app to show more than 50 saved videos.
if(docs.length > 49){
for (let i = 0; i < 49; i++) {
videoList = videoList + ',' + docs[i]['videoId'];
}
}
else{
docs.forEach((video) => {
videoList = videoList + ',' + video['videoId'];
});
}
// Call the YouTube API
let request = gapi.client.youtube.videos.list({
part: 'snippet',
id: videoList,
maxResults: 50,
});
// Execute the API request
request.execute((response) => {
// Render the videos to the screen
createVideoListContainer('Saved Videos:');
response['items'].forEach((video) => {
displayVideos(video, 'history');
});
toggleLoading();
});
});
}

315
src/js/settings.js Normal file
View File

@ -0,0 +1,315 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* A file for functions used for settings.
*/
/**
* Display the settings screen to the user.
*
* @return {Void}
*/
function showSettings(){
clearMainContainer();
toggleLoading();
let isChecked = '';
let key = '';
/*
* Check the settings database for the user's current settings. This is so the
* settings page has the correct toggles related when it is rendered.
*/
settingsDb.find({}, (err, docs) => {
docs.forEach((setting) => {
switch (setting['_id']) {
case 'apiKey':
// If a third party forks the application, please be ethical and change the API key.
if(setting['value'] !== 'AIzaSyDjszXMCw44W_k-pdNoOxUHFyKGtU_ejwE'){
key = setting['value'];
}
break;
case 'theme':
if (currentTheme == ''){
currentTheme = setting['value'];
}
}
});
// Grab the settings.html template to prepare for rendering
$.get('templates/settings.html', (template) => {
mustache.parse(template);
const rendered = mustache.render(template, {
isChecked: isChecked,
key: key,
});
// Render template to application
$('#main').html(rendered);
toggleLoading();
// Check / uncheck the switch depending on the user's settings.
if (currentTheme === 'light'){
document.getElementById('themeSwitch').checked = false;
}
else{
document.getElementById('themeSwitch').checked = true;
}
});
});
}
/**
* Check the user's default settings. Set the the default settings if none are found.
*
* @return {Void}
*/
function checkDefaultSettings(){
// Check settings database
settingsDb.find({}, (err, docs) => {
if (jQuery.isEmptyObject(docs)) {
// Set User Defaults
let themeDefault = {
_id: 'theme',
value: 'light',
};
let apiDefault = {
_id: 'apiKey',
// To any third party devs that fork the project, please be ethical and change the API key.
value: 'AIzaSyDjszXMCw44W_k-pdNoOxUHFyKGtU_ejwE',
};
// Set API Key and default theme
let apiKey = apiDefault.value;
setTheme('light');
// Inset default settings into the settings database.
settingsDb.insert(themeDefault);
settingsDb.insert(apiDefault);
} else {
// Use user current defaults
docs.forEach((setting) => {
switch (setting['_id']) {
case 'theme':
setTheme(setting['value']);
break;
case 'apiKey':
apiKey = setting['value'];
break;
default:
break;
}
});
}
// Loads the JavaScript client library and invokes `start` afterwards.
gapi.load('client', start);
});
}
/**
* Updates the settings based on what the user has changed.
*
* @return {Void}
*/
function updateSettings(){
var themeSwitch = document.getElementById('themeSwitch').checked;
var key = document.getElementById('api-key').value;
if (themeSwitch == true){
var theme = 'dark';
}
else{
var theme = 'light';
}
// Update default theme
settingsDb.update({_id: 'theme'},{value: theme}, {}, function(err, numReplaced){
console.log(err);
console.log(numReplaced);
});
if(key != ''){
settingsDb.update({_id: 'apiKey'},{value: key}, {});
}
else{
// To any third party devs that fork the project, please be ethical and change the API key.
settingsDb.update({_id: 'apiKey'},{value: 'AIzaSyDjszXMCw44W_k-pdNoOxUHFyKGtU_ejwE'}, {});
}
showToast('Settings have been saved.');
}
/**
* Toggle back and forth with the current theme
*
* @param {boolean} themeValue - The value of the switch based on if it was turned on or not.
*
* @return {Void}
*/
function toggleTheme(themeValue){
if(themeValue.checked === true){
setTheme('dark');
currentTheme = 'dark';
}
else{
setTheme('light');
currentTheme = 'light';
}
}
/**
* Set the theme of the application
*
* @param {string} option - The theme to be changed to.
*
* @return {Void}
*/
function setTheme(option){
let cssFile;
const currentTheme = document.getElementsByTagName("link").item(1);
// Create a link element
const newTheme = document.createElement("link");
newTheme.setAttribute("rel", "stylesheet");
newTheme.setAttribute("type", "text/css");
// Grab the css file to be used.
switch (option) {
case 'light':
cssFile = 'style/lightTheme.css';
break;
case 'dark':
cssFile = 'style/darkTheme.css';
break;
default:
// Default to the light theme
cssFile = 'style/lightTheme.css';
break;
}
newTheme.setAttribute("href", cssFile);
// Replace the current theme with the new theme
document.getElementsByTagName("head").item(0).replaceChild(newTheme, currentTheme);
}
/**
* Import a subscriptions file that the user provides.
*
* @return {Void}
*/
function importSubscriptions(){
const appDatabaseFile = localDataStorage + '/subscriptions.db';
// Open user's file browser. Only show .db files.
dialog.showOpenDialog({
properties: ['openFile'],
filters: [
{name: 'Database File', extensions: ['db']},
]
}, function(fileLocation){
if(typeof(fileLocation) === 'undefined'){
console.log('Import Aborted');
return;
}
if(typeof(fileLocation) !== 'object'){
showToast('Incorrect filetype. Import Aborted.');
return;
}
fs.readFile(fileLocation[0], function(readErr, data){
if(readErr){
showToast('Unable to read file. File may be corrupt or have invalid permissions.');
throw readErr;
}
fs.writeFile(appDatabaseFile, data, function(writeErr){
if(writeErr){
showToast('Unable to create file. Please check your permissions and try again.');
throw writeErr;
}
showToast('Susbcriptions have been successfully imported. Please restart FreeTube for the changes to take effect.');
});
})
});
}
/**
* Export the susbcriptions database to a file.
*
* @return {Void}
*/
function exportSubscriptions(){
const appDatabaseFile = localDataStorage + '/subscriptions.db';
// Open user file browser. User gives location of file to be created.
dialog.showSaveDialog({
filters: [
{name: 'Database File', extensions: ['db']},
]
}, function(fileLocation){
console.log(fileLocation);
if(typeof(fileLocation) === 'undefined'){
console.log('Export Aborted');
return;
}
fs.readFile(appDatabaseFile, function(readErr, data){
if(readErr){
throw readErr;
}
fs.writeFile(fileLocation, data, function(writeErr){
if(writeErr){
throw writeErr;
}
showToast('Susbcriptions have been successfully exported');
});
})
});
}
/**
* Clear out the data in a file.
*
* @param {string} type - The type of file to be cleared.
*/
function clearFile(type){
console.log(type);
let dataBaseFile;
switch (type) {
case 'subscriptions':
dataBaseFile = localDataStorage + '/subscriptions.db';
break;
case 'history':
dataBaseFile = localDataStorage + '/videohistory.db';
break;
case 'saved':
dataBaseFile = localDataStorage + '/savedvideos.db';
break;
default:
showToast('Unknown file: ' + type)
return
}
// Replace data with an empty string.
fs.writeFile(dataBaseFile, '', function(err){
if(err){
throw err;
}
showToast('File has been cleared. Restart FreeTube to see the changes');
})
}

272
src/js/subscriptions.js Normal file
View File

@ -0,0 +1,272 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* File for all functions related to subscriptions.
*/
/**
* Add a channel to the user's subscription database.
*
* @param {string} channelId - The channel ID to add to the subscriptions database.
*
* @return {Void}
*/
function addSubscription(channelId) {
console.log(channelId);
// Request YouTube API
let request = gapi.client.youtube.channels.list({
part: 'snippet',
id: channelId,
});
// Execute API request
request.execute((response) => {
const channelInfo = response['items'][0]['snippet'];
const channelName = channelInfo['title'];
const thumbnail = channelInfo['thumbnails']['high']['url'];
const data = {
channelId: channelId,
channelName: channelName,
channelThumbnail: thumbnail,
};
// Refresh the list of subscriptions on the side navigation bar.
subDb.insert(data, (err, newDoc) => {
displaySubs();
});
showToast('Added ' + channelName + ' to subscriptions.');
});
}
/**
* Remove a channel from the subscriptions database.
*
* @param {string} channelId - The channel ID to be removed.
*
* @return {Void}
*/
function removeSubscription(channelId) {
subDb.remove({
channelId: channelId
}, {}, (err, numRemoved) => {
// Refresh the list of subscriptions on the side navigation bar.
displaySubs();
showToast('Removed channel from subscriptions.');
});
}
/**
* Load the recent uploads of the user's subscriptions.
*
* @return {Void}
*/
function loadSubscriptions() {
clearMainContainer();
const loading = document.getElementById('loading');
/*
* It is possible for the function to be called several times. This prevents the loading
* from being turned off when the situation occurs.
*/
if (loading.style.display !== 'inherit'){
toggleLoading();
}
let videoList = [];
const subscriptions = returnSubscriptions();
// Welcome to callback hell, we hope you enjoy your stay.
subscriptions.then((results) => {
// Yes, This function is the thing that needs to most improvment
if (results.length > 0) {
showToast('Getting Subscriptions. This may take a while...');
/*
* If this loop gets rewritten, we can remove the asyncLoop dependency from the project.
*
* I wasn't able to figure out a way to loop through the list of channels and grab the recent uploads
* while then sorting them afterwards, this was my best solution at the time. I'm sure someone more
* experienced in Node can help out with this.
*/
asyncLoop(results, (sub, next) => {
const channelId = sub['channelId'];
/*
* Grab the channels 15 most recent uploads. Typically this should be enough.
* This number can be changed if we feel necessary.
*/
try {
let request = gapi.client.youtube.search.list({
part: 'snippet',
channelId: channelId,
type: 'video',
maxResults: 15,
order: 'date',
});
request.execute((response) => {
videoList = videoList.concat(response['items']);
// Iterate through the next object in the loop.
next();
});
} catch (err) {
/*
* The above API requests sometimes forces an error for some reason. Restart
* the function to prevent this. This should be changed if possible.
*/
loadSubscriptions();
return;
}
}, (err) => {
// Sort the videos by date
videoList.sort((a, b) => {
const date1 = Date.parse(a.snippet.publishedAt);
const date2 = Date.parse(b.snippet.publishedAt);
return date2.valueOf() - date1.valueOf();
});
// Render the videos to the application.
createVideoListContainer('Latest Subscriptions:');
// The YouTube website limits the subscriptions to 100 before grabbing more so we only show 100
// to keep the app running at a good speed.
if(videoList.length < 100){
videoList.forEach((video) => {
console.log('Getting all videos');
displayVideos(video);
});
}
else{
console.log('Getting top 100 videos');
for(let i = 0; i < 100; i++){
displayVideos(videoList[i]);
}
}
toggleLoading();
});
} else {
// User has no subscriptions. Display message.
const container = document.getElementById('main');
toggleLoading();
container.innerHTML = `<h2 class="message">Your Subscription list is currently empty. Start adding subscriptions
to see them here.<br /><br /><i class="far fa-frown" style="font-size: 200px"></i></h2>`;
}
});
}
/**
* Get the list of subscriptions from the user's subscription database.
*
* @return {promise} The list of subscriptions.
*/
function returnSubscriptions() {
return new Promise((resolve, reject) => {
subDb.find({}, (err, subs) => {
resolve(subs);
});
});
}
/**
* Display the list of subscriptions on the side navigation bar.
*
* @return {Void}
*/
function displaySubs() {
const subList = document.getElementById('subscriptions');
subList.innerHTML = '';
// Sort alphabetically
subDb.find({}).sort({
channelName: 1
}).exec((err, subs) => {
subs.forEach((channel) => {
// Grab subscriptions.html to be used as a template.
$.get('templates/subscriptions.html', (template) => {
mustache.parse(template);
const rendered = mustache.render(template, {
channelIcon: channel['channelThumbnail'],
channelName: channel['channelName'],
channelId: channel['channelId'],
});
// Render template to page.
const subscriptionsHtml = $('#subscriptions').html();
$('#subscriptions').html(subscriptionsHtml + rendered);
});
});
});
// Add onclick function
$('#subscriptions .fa-times').onClick = removeSubscription;
}
/**
* Adds / Removes a subscription based on if the channel is in the database or not.
* @param {string} channelId - The channel ID to check
*
* @return {Void}
*/
function toggleSubscription(channelId) {
event.stopPropagation();
const checkIfSubscribed = isSubscribed(channelId);
const subscribeButton = document.getElementById('subscribeButton');
checkIfSubscribed.then((results) => {
if (results === false) {
if(subscribeButton != null){
subscribeButton.innerHTML = 'UNSUBSCRIBE';
}
addSubscription(channelId);
} else {
if(subscribeButton != null){
subscribeButton.innerHTML = 'SUBSCRIBE';
}
removeSubscription(channelId);
}
});
}
/**
* Check if the user is subscribed to a channel or not.
*
* @param {string} channelId - The channel ID to check
*
* @return {promise} - A boolean value if the channel is currently subscribed or not.
*/
function isSubscribed(channelId) {
return new Promise((resolve, reject) => {
subDb.find({channelId: channelId}, (err, docs) => {
if (jQuery.isEmptyObject(docs)) {
resolve(false);
} else {
resolve(true);
}
});
});
}

662
src/js/videos.js Normal file
View File

@ -0,0 +1,662 @@
/*
This file is part of FreeTube.
FreeTube is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
FreeTube is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
You should have received a copy of the GNU General Public License
along with FreeTube. If not, see <http://www.gnu.org/licenses/>.
*/
/*
* File for functions related to videos.
* TODO: Split some of these functions into their own file.
*/
/**
* Perform a search using the YouTube API. The search query is grabbed from the #search element.
*
* @param {string} nextPageToken - Optional: The page token to be inlcuded in the search.
*
* @return {Void}
*/
function search(nextPageToken = '') {
const query = document.getElementById('search').value;
if (query === '') {
showToast('Search Field empty. Please input a search term.');
return;
}
if (nextPageToken === '') {
clearMainContainer();
toggleLoading();
} else {
console.log(nextPageToken);
showToast('Fetching results. Please wait...');
}
// Start API request
let request = gapi.client.youtube.search.list({
q: query,
part: 'id, snippet',
type: 'video',
pageToken: nextPageToken,
maxResults: 25,
});
// Execute API Request
request.execute((response) => {
console.log(response);
if (nextPageToken === '') {
createVideoListContainer('Search Results:');
toggleLoading();
}
response['items'].forEach(displayVideos);
addNextPage(response['result']['nextPageToken']);
});
}
/**
* Display a video on the page. Function is typically contained in a loop.
*
* @param {string} video - The video ID of the video to be displayed.
* @param {string} listType - Optional: Specifies the list type of the video
* Used for displaying the remove icon for history and saved videos.
*
* @return {Void}
*/
function displayVideos(video, listType = null) {
// Grab the search template for the video.
$.get('templates/videoList.html', (template) => {
const videoSnippet = video['snippet']
// Grab the published date for the video and convert to a user readable state.
const dateString = new Date(videoSnippet['publishedAt']);
const publishedDate = dateFormat(dateString, "mmm dS, yyyy");
let deleteHtml = '';
let liveText = '';
let videoId = video['id']['videoId'];
const searchMenu = $('#videoListContainer').html();
// Include a remove icon in the list if the application is displaying the history list or saved videos.
if (listType === 'saved') {
videoId = video['id'];
deleteHtml = '<i onclick="removeSavedVideo(\'' + videoId + '\'); showSavedVideos();" class="videoDelete fas fa-times"></i>';
} else if (listType === 'history') {
videoId = video['id'];
deleteHtml = '<i onclick="removeFromHistory(\'' + videoId + '\'); showHistory()" class="videoDelete fas fa-times"></i>';
}
// Includes text if the video is live.
if (videoSnippet['liveBroadcastContent'] === 'live') {
liveText = 'LIVE NOW';
}
// Render / Manipulate the template. Replace variables with data from the video.
mustache.parse(template);
const rendered = mustache.render(template, {
videoThumbnail: videoSnippet['thumbnails']['medium']['url'],
videoTitle: videoSnippet['title'],
channelName: videoSnippet['channelTitle'],
videoDescription: videoSnippet['description'],
publishedDate: publishedDate,
liveText: liveText,
videoId: videoId,
channelId: videoSnippet['channelId'],
deleteHtml: deleteHtml,
});
// Apply the render to the page
let nextButton = document.getElementById('getNextPage');
if (nextButton === null) {
$('#videoListContainer').append(rendered);
} else {
$(rendered).insertBefore('#getNextPage');
}
});
}
/**
* Changes the page token to the next page button during a video search.
*
* @param {string} nextPageToken - The page token to replace the button function.
*
* @return {Void}
*/
function addNextPage(nextPageToken) {
let oldFetchButton = document.getElementById('getNextPage');
// Creates the element if it doesn't exist.
if (oldFetchButton === null) {
let fetchButton = document.createElement('div');
fetchButton.id = 'getNextPage';
fetchButton.innerHTML = '<i class="fas fa-search"></i> Fetch more results...';
$('#videoListContainer').append(fetchButton);
}
// Update the on click method of the button.
$(document).off('click', '#getNextPage');
$(document).on('click', '#getNextPage', (event) => {
search(nextPageToken);
});
}
/**
* Display the video player and play a video
*
* @param {string} videoId - The video ID of the video to be played.
*
* @return {Void}
*/
function playVideo(videoId) {
clearMainContainer();
toggleLoading();
let subscribeText = '';
let savedText = '';
let savedIconClass = '';
let savedIconColor = '';
let video480p;
let video720p;
let defaultUrl;
let defaultQuality;
let videoHtml;
let videoThumbnail;
let videoType = 'video';
let embedPlayer;
let validUrl;
// Grab the embeded player. Used as fallback if the video URL cannot be found.
try {
let getEmbedFunction = getEmbedPlayer(videoId);
getEmbedFunction.then((url) => {
embedPlayer = url;
});
} catch (ex) {
showToast('Video not found. ID may be invalid.');
toggleLoading();
return;
}
/*
* FreeTube calls the HookTube API so that it can get the direct video URL instead of the embeded player.
* If anyone knows how to grab these files without relying on their API it would be very welcome.
* It helps that HookTube returns mostly the same information as a YouTube API call so performance
* shouldn't be hindered by this.
*/
const url = 'https://hooktube.com/api?mode=video&id=' + videoId;
$.getJSON(url, (response) => {
console.log(response);
const videoSummary = response['json_1'];
const videoSnippet = response['json_2']['items'][0]['snippet'];
const videoStatistics = response['json_2']['items'][0]['statistics'];
// Sometimes the max resolution URL isn't found. Grab the default one as a fallback.
try {
videoThumbnail = videoSnippet['thumbnails']['maxres']['url'];
} catch (e) {
videoThumbnail = videoSnippet['thumbnails']['default']['url'];
}
// Search through the returned object to get the 480p and 720p video URLs (If available)
Object.keys(videoSummary['link']).forEach((key) => {
console.log(key);
switch (videoSummary['link'][key][2]) {
case 'medium':
video480p = videoSummary['link'][key][0];
break;
case 'hd720':
video720p = videoSummary['link'][key][0];
break;
}
});
// Default to the embeded player if the URLs cannot be found.
if (typeof(video720p) === 'undefined' && typeof(video480p) === 'undefined') {
defaultQuality = 'EMBED';
videoHtml = embedPlayer.replace(/\&quot\;/g, '"');
showToast('Unable to get video file. Reverting to embeded player.');
} else if (typeof(video720p) === 'undefined' && typeof(video480p) !== 'undefined') {
// Default to the 480p video if the 720p URL cannot be found.
videoHtml = '<video class="videoPlayer" controls="" src="' + video480p + '" poster="' + videoThumbnail + '" autoplay></video>';
defaultQuality = '480p';
} else {
// Default to the 720p video.
videoHtml = '<video class="videoPlayer" controls="" src="' + video720p + '" poster="' + videoThumbnail + '" autoplay></video>';
defaultQuality = '720p';
// Force the embeded player if needed.
//videoHtml = embedPlayer;
}
// Add commas to the video view count.
const videoViews = videoSummary['view_count'].toString().replace(/\B(?=(\d{3})+(?!\d))/g, ",");
// Format the date to a more readable format.
const dateString = videoSnippet['publishedAt'];
const publishedDate = dateFormat(dateString, "mmm dS, yyyy");
const channelId = videoSnippet['channelId'];
let description = videoSnippet['description'];
const checkSubscription = isSubscribed(channelId);
// Change the subscribe button text depending on if the user has subscribed to the channel or not.
checkSubscription.then((results) => {
if (results === false) {
subscribeText = 'SUBSCRIBE';
} else {
subscribeText = 'UNSUBSCRIBE';
}
});
const checkSavedVideo = videoIsSaved(videoId);
// Change the save button icon and text depending on if the user has saved the video or not.
checkSavedVideo.then((results) => {
if (results === false) {
savedText = 'SAVE';
savedIconClass = 'far unsaved';
} else {
savedText = 'SAVED';
savedIconClass = 'fas saved';
}
});
// Figure out the width for the like/dislike bar.
const videoLikes = parseInt(videoStatistics['likeCount']);
const videoDislikes = parseInt(videoStatistics['dislikeCount']);
const totalLikes = videoLikes + videoDislikes;
const likePercentage = parseInt((videoLikes / totalLikes) * 100);
// Adds clickable links to the description.
description = autolinker.link(description);
// API Request
let request = gapi.client.youtube.channels.list({
'id': channelId,
'part': 'snippet,contentDetails,statistics'
});
// Execute request
request.execute((response) => {
console.log(response);
const channelThumbnail = response['items'][0]['snippet']['thumbnails']['high']['url'];
$.get('templates/player.html', (template) => {
mustache.parse(template);
const rendered = mustache.render(template, {
videoHtml: videoHtml,
videoQuality: defaultQuality,
videoTitle: videoSummary['title'],
videoViews: videoViews,
videoThumbnail: videoThumbnail,
channelName: videoSummary['author'],
videoLikes: videoLikes,
videoDislikes: videoDislikes,
likePercentage: likePercentage,
videoId: videoId,
channelId: channelId,
channelIcon: channelThumbnail,
publishedDate: publishedDate,
description: description,
isSubscribed: subscribeText,
savedText: savedText,
savedIconClass: savedIconClass,
savedIconColor: savedIconColor,
video480p: video480p,
video720p: video720p,
embedPlayer: embedPlayer,
});
$('#main').html(rendered);
toggleLoading();
showVideoRecommendations(videoId);
console.log('done');
});
});
// Sometimes a video URL is found, but the video will not play. I believe the issue is
// that the video has yet to render for that quality, as the video will be available at a later time.
// This will check the URLs and switch video sources if there is an error.
checkVideoUrls(video480p, video720p);
// Add the video to the user's history
addToHistory(videoId);
});
}
/**
* Grab the video recommendations for a video. This does not get recommendations based on what you watch,
* as that would defeat the main purpose of using FreeTube. At any time you can check the video on HookTube
* and compare the recommendations there. They should be nearly identical.
*
* @param {string} videoId - The video ID of the video to get recommendations from.
*/
function showVideoRecommendations(videoId) {
let request = gapi.client.youtube.search.list({
part: 'snippet',
type: 'video',
relatedToVideoId: videoId,
maxResults: 15,
});
request.execute((response) => {
const recommendations = response['items'];
recommendations.forEach((data) => {
const snippet = data['snippet'];
const videoId = data['id']['videoId'];
const videoTitle = snippet['title'];
const channelName = snippet['channelTitle'];
const videoThumbnail = snippet['thumbnails']['medium']['url'];
const dateString = snippet['publishedAt'];
const publishedDate = dateFormat(dateString, "mmm dS, yyyy");
$.get('templates/recommendations.html', (template) => {
mustache.parse(template);
const rendered = mustache.render(template, {
videoId: videoId,
videoTitle: videoTitle,
channelName: channelName,
videoThumbnail: videoThumbnail,
publishedDate: publishedDate,
});
const recommendationHtml = $('#recommendations').html();
$('#recommendations').html(recommendationHtml + rendered);
});
});
});
}
/**
* Open up the mini player to watch the video outside of the main application.
*
* @param {string} videoThumbnail - The URL of the video thumbnail. Used to prevent another API call.
*
* @return {Void}
*/
function openMiniPlayer(videoThumbnail) {
let lastTime;
let videoHtml;
// Grabs whatever the HTML is for the current video player. Done this way to grab
// the HTML5 player (with varying qualities) as well as the YouTube embeded player.
if ($('.videoPlayer').length > 0) {
$('.videoPlayer').get(0).pause();
lastTime = $('.videoPlayer').get(0).currentTime;
videoHtml = $('.videoPlayer').get(0).outerHTML;
} else {
videoHtml = $('iframe').get(0).outerHTML;
}
// Create a new browser window.
const BrowserWindow = electron.remote.BrowserWindow;
let miniPlayer = new BrowserWindow({
width: 1200,
height: 700
});
// Use the miniPlayer.html template.
$.get('templates/miniPlayer.html', (template) => {
mustache.parse(template);
const rendered = mustache.render(template, {
videoHtml: videoHtml,
videoThumbnail: videoThumbnail,
startTime: lastTime,
});
// Render the template to the new browser window.
miniPlayer.loadURL("data:text/html;charset=utf-8," + encodeURI(rendered));
});
}
/**
* Check if a link is a valid YouTube/HookTube link and play that video. Gets input
* from the #jumpToInput element.
*
* @return {Void}
*/
function parseVideoLink() {
let input = document.getElementById('jumpToInput').value;
if (input === '') {
return;
}
// The regex to get the video id from a YouTube link. Thanks StackOverflow.
let rx = /^.*(?:(?:(you|hook)tu\.?be\/|v\/|vi\/|u\/\w\/|embed\/)|(?:(?:watch)?\?v(?:i)?=|\&v(?:i)?=))([^#\&\?]*).*/;
let match = input.match(rx);
console.log(match);
// Play video if a match is found.
try {
playVideo(match[2]);
} catch (err) {
showToast('Video Not Found');
}
}
/**
* Grab the most popular videos over the last couple of days and display them.
*
* @return {Void}
*/
function showMostPopular() {
clearMainContainer();
toggleLoading();
// Get the date of 2 days ago.
var d = new Date();
d.setDate(d.getDate() - 2);
console.log(d.toString());
// Grab all videos published 2 days ago and after and order them by view count.
// These are the videos that are considered as 'most popular' and is how similar
// Applications grab these. Videos in the 'Trending' tab on YouTube will be different.
// And there is no way to grab those videos.
let request = gapi.client.youtube.search.list({
part: 'snippet',
order: 'viewCount',
type: 'video',
publishedAfter: d.toISOString(),
maxResults: 50,
});
request.execute((response) => {
console.log(response);
createVideoListContainer('Most Popular:');
toggleLoading();
response['items'].forEach(displayVideos);
});
}
/**
* Create a link of the video to HookTube or YouTube and copy it to the user's clipboard.
*
* @param {string} website - The website to watch the video on.
* @param {string} videoId - The video ID of the video to add to the URL
*
* @return {Void}
*/
function copyLink(website, videoId) {
// Create the URL and copy to the clipboard.
const url = 'https://' + website + '.com/watch?v=' + videoId;
clipboard.writeText(url);
showToast('URL has been copied to the clipboard');
}
/**
* Get the YouTube embeded player of a video.
*
* @param {string} videoId - The video ID of the video to get.
*
* @return {promise} - The HTML of the embeded player
*/
function getEmbedPlayer(videoId) {
console.log(videoId);
return new Promise((resolve, reject) => {
let request = gapi.client.youtube.videos.list({
part: 'player',
id: videoId,
});
request.execute((response) => {
console.log(response);
let embedHtml = response['items'][0]['player']['embedHtml'];
embedHtml = embedHtml.replace('src="', 'src="https:');
embedHtml = embedHtml.replace('width="480px"', '');
embedHtml = embedHtml.replace('height="270px"', '');
embedHtml = embedHtml.replace(/\"/g, '&quot;');
resolve(embedHtml);
});
});
}
/**
* Change the quality of the current video.
*
* @param {string} videoHtml - The HTML of the video player to be set.
* @param {string} qualityType - The Quality Type of the video. Ex: 720p, 480p
* @param {boolean} isEmbed - Optional: Value on if the videoHtml is the embeded player.
*
* @return {Void}
*/
function changeQuality(videoHtml, qualityType, isEmbed = false) {
if (videoHtml == '') {
showToast('Video quality type is not available. Unable to change quality.')
return;
}
videoHtml = videoHtml.replace(/\&quot\;/g, '"');
console.log(videoHtml);
console.log(isEmbed);
// The YouTube API creates 2 more iFrames. This is why a boolean value is sent
// with the function.
const embedPlayer = document.getElementsByTagName('IFRAME')[0];
const html5Player = document.getElementsByClassName('videoPlayer');
console.log(embedPlayer);
console.log(html5Player);
if (isEmbed && html5Player.length == 0) {
// The embeded player is already playing. Return.
showToast('You are already using the embeded player.')
return;
} else if (isEmbed) {
// Switch from HTML 5 player to embeded Player
html5Player[0].remove();
const mainHtml = $('#main').html();
$('#main').html(videoHtml + mainHtml);
$('#currentQuality').html(qualityType);
} else if (html5Player.length == 0) {
// Switch from embeded player to HTML 5 player
embedPlayer.remove();
let videoPlayer = document.createElement('video');
videoPlayer.className = 'videoPlayer';
videoPlayer.src = videoHtml;
videoPlayer.controls = true;
videoPlayer.autoplay = true;
$('#main').prepend(videoPlayer);
$('#currentQuality').html(qualityType);
} else {
// Switch src on HTML 5 player
const currentPlayBackTime = $('.videoPlayer').get(0).currentTime;
html5Player[0].src = videoHtml;
html5Player[0].load();
$('.videoPlayer').get(0).currentTime = currentPlayBackTime;
$('#currentQuality').html(qualityType);
}
}
/**
* Check to see if the video URLs are valid. Change the video quality if one is not.
* The API will grab video URLs, but they will sometimes return a 404. This
* is why this check is needed. The video URL will typically be resolved over time.
*
* @param {string} video480p - The URL to the 480p video.
* @param {string} video720p - The URL to the 720p video.
*/
function checkVideoUrls(video480p, video720p) {
const currentQuality = $('#currentQuality').html();
let buttonEmbed = document.getElementById('qualityEmbed');
let valid480 = false;
if (typeof(video480p) !== 'undefined'){
let get480pUrl = fetch(video480p);
get480pUrl.then((status) => {
switch (status.status) {
case 404:
showToast('Found valid URL for 480p, but returned a 404. Video type might be available in the future.');
$(document).off('click', '#quality480p');
$(document).on('click', '#quality480p', (event) => {
changeQuality('');
});
buttonEmbed.click();
return;
break;
case 403:
showToast('This video is unavailable in your country.');
$(document).off('click', '#quality480p');
$(document).on('click', '#quality480p', (event) => {
changeQuality('');
});
return;
break;
default:
console.log('480p is valid');
if (currentQuality === '720p' && typeof(video720p) === 'undefined'){
changeQuality(video480p);
}
break;
}
});
}
if (typeof(video720p) !== 'undefined'){
let get720pUrl = fetch(video720p);
get720pUrl.then((status) => {
switch (status.status) {
case 404:
showToast('Found valid URL for 720p, but returned a 404. Video type might be available in the future.');
$(document).off('click', '#quality720p');
$(document).on('click', '#quality720p', (event) => {
changeQuality('');
});
if (typeof(valid480) !== 'undefined'){
changeQuality(video480p, '480p');
}
break;
case 403:
showToast('This video is unavailable in your country.');
$(document).off('click', '#quality720p');
$(document).on('click', '#quality720p', (event) => {
changeQuality('');
});
return;
break;
default:
console.log('720p is valid');
if (currentQuality === '720p'){
return;
}
break;
}
});
}
}

44
src/style/channel.css Normal file
View File

@ -0,0 +1,44 @@
.channelViewBanner{
width: 100%;
margin-bottom: 30px;
}
.channelViewImage{
float: left;
width: 100px;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
}
.channelViewTitle{
height: 100px;
margin-left: 100px;
}
.channelViewName{
font-weight: bold;
font-size: 25px;
margin-left: 20px;
position: relative;
top: 20px;
}
.channelViewSubs{
margin-left: 20px;
margin-top: 20px;
position: relative;
top: 20px;
}
.channelSubButton{
float: right;
width: 125px;
height: 50px;
line-height: 50px;
text-align: center;
cursor: pointer;
}
.channelViewDescription{
white-space: pre-line;
}

62
src/style/darkTheme.css Normal file
View File

@ -0,0 +1,62 @@
::-webkit-scrollbar {
height: 12px;
width: 12px;
background: #262626;
}
::-webkit-scrollbar-thumb {
background: #212121;
-webkit-border-radius: 1ex;
-webkit-box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.75);
}
::-webkit-scrollbar-corner {
background: #262626;
}
body{background-color: #212121;}
input[type=text] {color: #EEEEEE;}
.sk-cube-grid .sk-cube {background-color: #f44336;}
.searchBar ::-webkit-input-placeholder {color: #E0E0E0;}
.topNav{background-color: #262626; -webkit-box-shadow: 0px -4px 20px 0px rgba(0,0,0,0.75);}
.logo{color: #f44336;}
.searchBar input{border-bottom: 1px solid #ddd;}
.searchButton{color: #E0E0E0;}
.jumpToInput{border-bottom: 1px solid #E0E0E0;}
.message{color: #757575;}
.videoDelete{color: white;}
.channelViewImage{border: 0px solid white;}
.channelViewName{color: #EEEEEE;}
.channelViewSubs{color: #BDBDBD;}
.channelViewDescription{color: #E0E0E0;}
.channelSubButton{color: #E0E0E0; background-color: #f44336;}
.videoTitle{color: #EEEEEE;}
.channelName{color: #E0E0E0;}
.videoDescription{color: #E0E0E0;}
.statistics{background-color: #424242; color: #EEEEEE;}
.views{color: #E0E0E0;}
.details{background-color: #424242; color: #EEEEEE;}
.playerSubButton{color: #E0E0E0; background-color: #f44336;}
.smallButton{color: #E0E0E0; background-color: #757575;}
.recommendDate{color: #E0E0E0;}
.settingsButton {color: #BDBDBD; background-color: #424242;}
.qualityTypes{color: #E0E0E0; background-color: #757575;}
.unsaved{color: #E0E0E0;}
#main{color: #EEEEEE;}
#main hr{border-bottom: 1px solid #212121;}
#subscriptions img{border: 0px solid white;}
#sideNav{background-color: #262626; color: #E0E0E0; -webkit-box-shadow: 4px -2px 51px -6px rgba(0,0,0,0.75);}
#sideNav hr{background-color: #f44336;}
#sideNav li:hover{background-color: #212121;}
#channelIcon{border: 0px solid white;}
#channelName{color: #E0E0E0;}
#publishDate{color: #E0E0E0;}
#description{color: #E0E0E0;}
#showComments{background-color: #757575; color: #E0E0E0;}
#recommendations{color: #EEEEEE;}
#videoListContainer{color: #EEEEEE;}
#toast{background-color: #616161; color: white;}
#confirmFunction{background-color: #616161; color: white;}
#getNextPage{background-color: #616161}
#comments{background-color: #424242;}

51
src/style/lightTheme.css Normal file
View File

@ -0,0 +1,51 @@
::-webkit-scrollbar {
height: 12px;
width: 12px;
background: white;
}
::-webkit-scrollbar-thumb {
background: #BDBDBD;
-webkit-border-radius: 1ex;
-webkit-box-shadow: 0px 1px 2px rgba(0, 0, 0, 0.75);
}
::-webkit-scrollbar-corner {
background: white;
}
body{background-color: #e0e0e0;}
.sk-cube-grid .sk-cube {background-color: #f44336;}
.searchBar ::-webkit-input-placeholder {color: #ddd;}
.topNav{background-color: #f44336; -webkit-box-shadow: 0px -4px 32px 0px rgba(0, 0, 0, 0.75);}
.searchBar input{border-bottom: 1px solid #ddd;}
.searchButton{color: black;}
.jumpToInput{border-bottom: 1px solid #757575;}
.message{color: #757575;}
.videoDelete{color: black;}
.channelViewImage{border: 0px solid #000000;}
.channelViewSubs{color: #757575;}
.channelSubButton{color: #616161; background-color: #eeeeee;}
.videoTitle{color: #000000;}
.channelName{color: #424242;}
.videoDescription{color: #424242;}
.statistics{background-color: white;}
.views{color: #424242;}
.details{background-color: white;}
.playerSubButton{color: #616161; background-color: #eeeeee;}
.smallButton{color: #616161; background-color: #eeeeee;}
.recommendDate{color: #616161;}
.settingsButton {color: #424242; background-color: #BDBDBD;}
.qualityTypes{background-color: #eeeeee;}
.unsaved{color: #616161;}
#subscriptions img{border: 0px solid #000000;}
#sideNav{background-color: white; -webkit-box-shadow: 4px -2px 51px -6px rgba(0,0,0,0.75);}
#sideNav hr{background-color: #f44336;}
#sideNav li:hover{background-color: #e0e0e0;}
#channelIcon{border: 0px solid #000000;}
#showComments{background-color: #eeeeee;}
#toast{background-color: #212121; color: #f5f5f5;;}
#confirmFunction{background-color: #f5f5f5;}
#getNextPage{background-color: #f5f5f5;}
#comments{background: #eee;}

46
src/style/loading.css Normal file
View File

@ -0,0 +1,46 @@
/*
* Thanks to @tobiasahlin for the loading animation.
* Find it here: http://tobiasahlin.com/spinkit/
* Twitter: https://twitter.com/tobiasahlin
*/
.spinner {
width: 40px;
height: 40px;
position: relative;
margin: 100px auto;
}
.double-bounce1, .double-bounce2 {
width: 100%;
height: 100%;
border-radius: 50%;
background-color: #f44336;
opacity: 0.6;
position: absolute;
top: 0;
left: 0;
-webkit-animation: sk-bounce 2.0s infinite ease-in-out;
animation: sk-bounce 2.0s infinite ease-in-out;
}
.double-bounce2 {
-webkit-animation-delay: -1.0s;
animation-delay: -1.0s;
}
@-webkit-keyframes sk-bounce {
0%, 100% { -webkit-transform: scale(0.0) }
50% { -webkit-transform: scale(1.0) }
}
@keyframes sk-bounce {
0%, 100% {
transform: scale(0.0);
-webkit-transform: scale(0.0);
} 50% {
transform: scale(1.0);
-webkit-transform: scale(1.0);
}
}

355
src/style/main.css Normal file
View File

@ -0,0 +1,355 @@
body {
font-family: 'Roboto', sans-serif;
}
input {
box-sizing: border-box;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
outline: none;
width: 100%;
padding: 7px;
border: none;
background: transparent;
margin-bottom: 10px;
font: 16px;
height: 45px;
}
a {
color: #2196F3;
}
.center {
text-align: center;
margin: 0 auto;
}
.topNav {
width: 100%;
height: 60px;
line-height: 60px;
position: fixed;
top: 0;
left: 0;
z-index: 1;
}
.topNav span {
display: inline-block;
vertical-align: middle;
line-height: normal;
}
.menuButton {
cursor: pointer;
}
.logo {
font-weight: bold;
font-size: 20px;
margin-left: 20px;
}
.searchBar {
position: absolute;
right: 0px;
top: 0;
width: 60%;
}
.searchBar input {
width: 40%;
}
.searchButton {
text-decoration: none;
}
.jumpToInput {
width: 80%;
}
#sideNav {
height: 100vh;
width: 250px;
overflow-y: auto;
position: fixed;
left: 0;
top: 0px;
-webkit-box-shadow: 4px -2px 51px -6px rgba(0, 0, 0, 0.75);
}
#sideNav hr {
width: 95%;
height: 1px;
border: 0;
}
#sideNav ul {
list-style-type: none;
}
#sideNav li {
padding: 20px;
width: 205px;
position: relative;
right: 50px;
cursor: pointer;
-webkit-transition: background 0.2s ease-out;
-moz-transition: background 0.2s ease-out;
-o-transition: background 0.2s ease-out;
transition: background 0.2s ease-out;
}
#sideNav li:hover {
-moz-transition: background 0.2s ease-in;
-o-transition: background 0.2s ease-in;
transition: background 0.2s ease-in;
}
#sideNavDisabled {
height: 100vh;
width: 250px;
position: fixed;
left: 0;
top: 0px;
z-index: 1;
margin-top: 85px;
display: none;
}
.sideNavContainer {
margin-top: 85px;
}
#subscriptions img {
float: left;
width: 40px;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
position: relative;
top: -12px;
margin-right: 5px;
}
#subscriptions .fa-times {
float: right;
margin-right: 5px;
}
#subscriptions li {
font-size: 11px;
}
.message {
text-align: center;
}
.videoDelete {
float: right;
cursor: pointer;
}
#loading {
width: 75%;
height: 20%;
margin: auto;
position: absolute;
top: 0;
left: 5;
bottom: 0;
right: 0;
display: none;
}
.settingsInput {
width: 50%;
border-bottom: 1px solid #616161;
}
.settingsButton {
padding: 10px;
display: inline-block;
cursor: pointer;
margin: 5px;
-webkit-box-shadow: 4px 4px 10px 0px rgba(0, 0, 0, 0.75);
-moz-box-shadow: 4px 4px 10px 0px rgba(0, 0, 0, 0.75);
box-shadow: 4px 4px 10px 0px rgba(0, 0, 0, 0.75);
}
.settingsSubmit {
padding: 15px;
color: #212121;
background-color: #f44336;
cursor: pointer;
width: 150px;
-webkit-box-shadow: 4px 4px 10px 0px rgba(0, 0, 0, 0.75);
-moz-box-shadow: 4px 4px 10px 0px rgba(0, 0, 0, 0.75);
box-shadow: 4px 4px 10px 0px rgba(0, 0, 0, 0.75);
}
.help {
margin: 30px;
}
.live {
font-size: 12px;
height: 20px;
text-align: center;
width: 57px;
position: relative;
left: 284px;
color: #f44336;
bottom: 15px;
line-height: 10px;
}
#toast{
min-width: 400px;
height: 50px;
position: fixed;
bottom: 20px;
right: 20px;
padding: 10px;
text-align: center;
font-size: 17px;
line-height: 50px;
opacity: 0;
visibility: hidden;
-webkit-transition: opacity 0.5s ease-in-out;
-moz-transition: opacity 0.5s ease-in-out;
-ms-transition: opacity 0.5s ease-in-out;
-o-transition: opacity 0.5s ease-in-out;
}
.closeToast{
font-size: 15px;
position: absolute;
top: 5px;
right: 10px;
cursor: pointer;
}
#confirmFunction{
position: fixed;
top: 50%;
left: 30%;
width: 800px;
height: 65px;
font-weight: bold;
line-height: 65px;
visibility: hidden;
-webkit-box-shadow: 5px 5px 15px -5px rgba(0,0,0,0.75);
-moz-box-shadow: 5px 5px 15px -5px rgba(0,0,0,0.75);
box-shadow: 5px 5px 15px -5px rgba(0,0,0,0.75);
}
#confirmMessage{
margin-left: 15px;
}
.confirmButton{
position: absolute;
top: 0px;
cursor: pointer;
}
#confirmYes{
right: 90px;
color: #2196f3;
}
#confirmNo{
right: 20px;
}
#getNextPage{
width: 100%;
height: 45px;
line-height: 45px;
text-align: center;
cursor: pointer;
}
#comments{
display:none;
padding:1em;
margin-bottom: 1em;
}
.saved{color: #FFEB3B;}
/* Thanks to Guus Lieben for the Material Design Switch */
.switch-input {
display: none;
}
.switch-label {
position: relative;
display: inline-block;
min-width: 112px;
cursor: pointer;
font-weight: 500;
text-align: left;
margin: 16px;
padding: 16px 0 16px 44px;
}
.switch-label:before, .switch-label:after {
content: "";
position: absolute;
margin: 0;
outline: 0;
top: 50%;
-ms-transform: translate(0, -50%);
-webkit-transform: translate(0, -50%);
transform: translate(0, -50%);
-webkit-transition: all 0.3s ease;
transition: all 0.3s ease;
}
.switch-label:before {
left: 1px;
width: 34px;
height: 14px;
background-color: #9E9E9E;
border-radius: 8px;
}
.switch-label:after {
left: 0;
width: 20px;
height: 20px;
background-color: #FAFAFA;
border-radius: 50%;
box-shadow: 0 3px 1px -2px rgba(0, 0, 0, 0.14), 0 2px 2px 0 rgba(0, 0, 0, 0.098), 0 1px 5px 0 rgba(0, 0, 0, 0.084);
}
.switch-label .toggle--on {
display: none;
}
.switch-label .toggle--off {
display: inline-block;
}
.switch-input:checked+.switch-label:before {
background-color: #90CAF9;
}
.switch-input:checked+.switch-label:after {
background-color: #2196F3;
-ms-transform: translate(80%, -50%);
-webkit-transform: translate(80%, -50%);
transform: translate(80%, -50%);
}
.switch-input:checked+.switch-label .toggle--on {
display: inline-block;
}
.switch-input:checked+.switch-label .toggle--off {
display: none;
}

218
src/style/player.css Normal file
View File

@ -0,0 +1,218 @@
iframe{
width: 100%;
height: 41.25vw;
}
#main{
margin-top: 80px;
margin-left: 250px;
}
.video{
width: 95%;
max-width: 1000px;
height: 145px;
padding: 15px;
overflow: hidden;
}
.videoThumbnail{
width: 275px;
float: left;
cursor: pointer;
}
.videoTitle{
font-weight: bold;
margin-left: 285px;
margin-top: 5px;
cursor: pointer;
}
.channelName{
margin-left: 285px;
font-size: 14px;
cursor: pointer;
}
.videoDescription{
margin-left: 285px;
font-size: 13px;
cursor: pointer;
}
.videoPlayer{width: 100%;}
.statistics{
padding: 20px;
padding-bottom: 45px;
}
.title{
font-weight: bold;
font-size: 25px;
}
.views{
margin-top: -10px;
float: left;
}
.details{
padding: 15px;
}
.likeContainer{
width: 300px;
float: right;
}
.likes{
float: left;
font-size: 12px;
}
.dislikes{
float: right;
font-size: 12px;
}
.dislikeBar{
background-color: #9E9E9E;
width: 300px;
height: 5px;
margin-bottom: 5px;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
}
.likeBar{
height: 5px;
background-color: #2196F3;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
}
#channelIcon{
float: left;
width: 80px;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
cursor: pointer;
}
#comments .line {
clear: both;
height: 1px;
background: #d8d8d8;
}
.userIcon {
float: left;
width: 48px;
border-radius: 200px 200px 200px 200px;
-webkit-border-radius: 200px 200px 200px 200px;
cursor: pointer;
}
#channelName{
font-weight: bold;
margin-left: 95px;
font-size: 16px;
cursor: pointer;
}
#publishDate{
margin-left: 95px;
font-size: 13px;
margin-top: -10px;
}
#description{
white-space: pre-line;
max-height: 200px;
overflow-y: auto;
}
.playerSubButton{
float: right;
width: 125px;
height: 50px;
line-height: 50px;
text-align: center;
margin-top: -65px;
cursor: pointer;
}
.smallButton{
float: right;
height: 30px;
font-size: 10px;
line-height: 30px;
text-align: center;
margin-right: 5px;
padding-left: 15px;
padding-right: 15px;
cursor: pointer;
}
.videoQuality{
width: 42px;
}
.videoQuality:hover .qualityTypes{
visibility: visible;
}
.qualityTypes{
visibility: hidden;
width: 72px;
position: relative;
bottom: 10px;
right: 15px;
}
.qualityTypes ul{
list-style-type: none;
position: relative;
right: 24px;
}
#showComments{
text-align: center;
height: 40px;
line-height: 40px;
margin-top: 15px;
margin-bottom: 15px;
}
#recommendations{
width: 100%;
}
.recommendVideo{
cursor: pointer;
height: 150px;
}
.recommendThumbnail{
width: 250px;
float: left;
}
.recommendTitle{
font-size: 16px;
font-weight: bold;
margin-left: 260px;
}
.recommendChannel{
margin-left: 260px;
font-size: 14px;
margin-top: -10px;
}
.recommendDate{
margin-left: 260px;
font-size: 11px;
}

7
src/style/videoList.css Normal file
View File

@ -0,0 +1,7 @@
.videoListContainer{width: 100%;}
.videoListContainer hr{
width: 95%;
height: 1px;
border: 0;
}

7
src/templates/about.html Normal file
View File

@ -0,0 +1,7 @@
<h1 class='center'>FreeTube {{versionNumber}} Beta</h1>
<br />
<h2 class='center'>This software is FOSS and released under the <a href='https://www.gnu.org/licenses/quick-guide-gplv3.html'>GNU Public License v3</a></h2>
<br />
<p class='center'>
Found a bug? Want to suggest a feature? Want to help out? Check out our <a href='https://github.com/FreeTubeApp/FreeTube'>GitHub</a> page. Pull requests are welcome.
</p>

View File

@ -0,0 +1,21 @@
<img class='channelViewBanner' src='{{channelBanner}}' />
<br />
<div class='channelViewTitle'>
<img class='channelViewImage' src='{{channelImage}}' />
<span class='channelViewName'>{{channelName}}</span>
<br />
<span class='channelViewSubs'>{{subCount}} Subscribers</span>
<div id='subscribeButton' class='channelSubButton' onclick='toggleSubscription("{{channelId}}");'>
{{isSubscribed}}
</div>
</div>
<br />
<hr />
<div class='channelViewDescription'>
{{{channelDescription}}}
</div>
<br />
<hr />
<div id='videoListContainer'>
<h2>Latest Uploads</h2>
</div>

View File

@ -0,0 +1,14 @@
{{#comments}}
<div class="user-comment">
<div onclick='goToChannel("{{authorChannelId.value}}")' style="float:left;margin-right: 1em; width:48px">
<img class="userIcon" src="{{authorProfileImageUrl}}" alt="" class="user-icon" />
</div>
<div class="comment-data">
<p onclick='goToChannel("{{authorChannelId.value}}")' style='cursor: pointer; margin-left: 65px; font-weight: bold;'>{{authorDisplayName}}</p>
<p style="margin-bottom: 0; margin-left: 65px; word-break: break-word;">{{textOriginal}}</p>
<p style="font-size:80%; padding: 0; margin-left: 65px;">{{publishedAt}}</p>
</div>
</div>
<br/>
<div class="line"></div>
{{/comments}}

View File

@ -0,0 +1,30 @@
<title>Freetube Mini-Player</title>
<style>
body{
background-color: #424242;
}
.videoPlayer{
width: 100%;
}
iframe{
width: 100%;
height: 54.9vw;
}
</style>
<script src="../js/events.js"></script>
<script>
window.$ = window.jQuery = require('jquery');
let startTime = '{{startTime}}';
$('.videoPlayer').ready(() => {
$('.videoPlayer').get(0).currentTime = startTime;
});
let playPauseVideo = function(event) {
let el = event.currentTarget;
el.paused ? el.play() : el.pause();
}
$(document).on('click', '.videoPlayer', playPauseVideo);
</script>
{{{videoHtml}}}

55
src/templates/player.html Normal file
View File

@ -0,0 +1,55 @@
{{{videoHtml}}}
<div class='statistics'>
<div class='smallButton' onclick="openMiniPlayer('{{videoThumbnail}}')">
MINI PLAYER <i class="fas fa-external-link-alt"></i>
</div>
<div class='smallButton videoQuality'>
<span id='currentQuality'>{{videoQuality}}</span> <i class="fas fa-angle-down"></i>
<div class='qualityTypes'>
<ul>
<li id='quality480p' onclick='changeQuality("{{video480p}}", "480p")'>480p</li>
<li id='quality720p' onclick='changeQuality("{{video720p}}", "720p")'>720p</li>
<li id='qualityEmbed' onclick='changeQuality("{{embedPlayer}}", "EMBED", true)'>EMBED</li>
</ul>
</div>
</div>
<div onclick='toggleSavedVideo("{{videoId}}")' class='smallButton'>
<i id='saveIcon' style='color: {{savedIconColor}};' class="{{savedIconClass}} fa-star"></i> <span id='savedText'>{{savedText}}</span>
</div>
<div class='smallButton' onclick='copyLink("youtube", "{{videoId}}")'>
COPY YOUTUBE LINK
</div>
<div class='smallButton' onclick='copyLink("hooktube", "{{videoId}}")'>
COPY HOOKTUBE LINK
</div>
<br />
<p class='title'>{{videoTitle}}</p>
<p class='views'>{{videoViews}} views</p>
<div class='likeContainer'>
<div class='dislikeBar'>
<div class='likeBar' style='width: {{likePercentage}}%;'>
</div>
</div>
<span class='likes'><i class="fas fa-thumbs-up"></i> {{videoLikes}}</span><span class='dislikes'><i class="fas fa-thumbs-down"></i> {{videoDislikes}}</span>
</div>
</div>
<div class="details">
<img id='channelIcon' onclick='goToChannel("{{channelId}}")' src="{{channelIcon}}" />
<p id='channelName' onclick='goToChannel("{{channelId}}")'>{{channelName}}</p>
<p id='publishDate'>Published on {{publishedDate}}</p>
<div id='subscribeButton' class='playerSubButton' onclick='toggleSubscription("{{channelId}}")'>{{isSubscribed}}</div>
<br /><br />
<div id='description'>
{{{description}}}
</div>
</div>
<div id='showComments'>
Show Comments <i class="far fa-comments"></i> (Max of 100)
</div>
<div id='comments' data-video-id="{{videoId}}">
</div>
<div id='recommendations'>
<strong>Recommendations</strong>
</div>

View File

@ -0,0 +1,7 @@
<div class='recommendVideo' onclick='playVideo("{{videoId}}")'>
<image class='recommendThumbnail' src='{{videoThumbnail}}'></image>
<p class='recommendTitle'>{{videoTitle}}</p>
<p class='recommendChannel'>{{channelName}}</p>
<p class='recommendDate'>{{publishedDate}}</p>
</div>
<hr />

View File

@ -0,0 +1,33 @@
<h1 class="center">Settings</h1>
<div class='center'>
<input type='text' name='api-key' id='api-key' class='settingsInput' value='{{key}}' placeholder='API Key' />
<br />
<label for='api-key'>Set API Key: Leave blank to use default</label>
<br />
<input type="checkbox" id="themeSwitch" name="set-name" class="switch-input" onchange='toggleTheme(this)' {{isChecked}}>
<label for="themeSwitch" class="switch-label">Use <span class="toggle--on">Light</span><span class="toggle--off">Dark</span> Theme</label>
</div>
<div class='center'>
<div onclick='importSubscriptions()' class='settingsButton'>
IMPORT SUBSCRIPTIONS
</div>
<div onclick='exportSubscriptions();' class='settingsButton'>
EXPORT SUBSCRIPTIONS
</div>
</div>
<br /><br />
<div class='center'>
<div onclick='confirmFunction("Are you sure you want to delete your history?", clearFile, "history")' class='settingsButton'>
CLEAR HISTORY
</div>
<div onclick='confirmFunction("Are you sure you want to remove all saved videos?", clearFile, "saved")' class='settingsButton'>
CLEAR SAVED VIDEOS
</div>
<div onclick='confirmFunction("Are you sure you want to remove all subscriptions?", clearFile, "subscriptions")' class='settingsButton'>
CLEAR SUBSCRIPTIONS
</div>
</div>
<br /><br />
<div onclick='updateSettings()' class='center settingsSubmit'>
SAVE SETTINGS
</div>

View File

@ -0,0 +1,5 @@
<li onclick='goToChannel("{{channelId}}")'>
<img src='{{channelIcon}}' />
{{channelName}}
<i class='fas fa-times' onclick='toggleSubscription("{{channelId}}")'></i>
</li>

View File

@ -0,0 +1,9 @@
<div class='video'>
{{{deleteHtml}}}
<img onclick='playVideo("{{videoId}}")' src={{videoThumbnail}} class='videoThumbnail' />
<p onclick='playVideo("{{videoId}}")' class='videoTitle'>{{videoTitle}}</p>
<p onclick='goToChannel("{{channelId}}")' class='channelName'>{{channelName}} - {{publishedDate}}</p>
<p onclick='playVideo("{{videoId}}")' class='videoDescription'>{{videoDescription}}</p>
<p class='live'>{{liveText}}</p>
</div>
<hr />