@@ -24,6 +24,7 @@
|
||||
- Mini player.
|
||||
- Silence skipper (Experimental.)
|
||||
- ffmpeg integration for downloading videos with audio at any quality ([Configuration required](./docs/DOCKER.md#step-7-optional-enabling-downloads)).
|
||||
- [YouTube.js](https://github.com/LuanRT/YouTube.js) fallback if Invidious fails loading videos for Desktop.
|
||||
- Preview video on hover.
|
||||
- Sponsorblock built-in.
|
||||
- Return YouTube dislikes built-in.
|
||||
|
||||
+37
-3
@@ -276,24 +276,32 @@ VITE_DEFAULT_PEERJS_PORT: 443
|
||||
|
||||
## Step 7 (Optional): Enabling downloads
|
||||
|
||||
### Step 1: Add the following to your reverse proxy
|
||||
### Step 1: Add the following to your reverse proxies
|
||||
|
||||
#### Caddy
|
||||
|
||||
```caddy
|
||||
# Materialious
|
||||
materialious.example.com {
|
||||
reverse_proxy localhost:3001
|
||||
|
||||
header {
|
||||
Cross-Origin-Opener-Policy "same-origin"
|
||||
Cross-Origin-Embedder-Policy "require-corp"
|
||||
Cross-Origin-Opener-Policy "same-origin"
|
||||
Cross-Origin-Embedder-Policy "require-corp"
|
||||
}
|
||||
}
|
||||
|
||||
# Invidious
|
||||
invidious.example.com {
|
||||
# Other configurations
|
||||
|
||||
header /ggpht/* Cross-Origin-Resource-Policy cross-origin
|
||||
}
|
||||
```
|
||||
|
||||
#### Nginx
|
||||
```nginx
|
||||
# Materialious
|
||||
server {
|
||||
listen 80;
|
||||
server_name materialious.example.com;
|
||||
@@ -305,12 +313,23 @@ server {
|
||||
proxy_pass http://localhost:3001;
|
||||
}
|
||||
}
|
||||
|
||||
# Invidious
|
||||
server {
|
||||
listen 80;
|
||||
server_name invidious.example.com;
|
||||
|
||||
location /ggpht/ {
|
||||
add_header Cross-Origin-Resource-Policy cross-origin;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Traefik
|
||||
Add the following to Materialious
|
||||
|
||||
```yaml
|
||||
# Materialious
|
||||
http:
|
||||
middlewares:
|
||||
materialious-downloads:
|
||||
@@ -318,6 +337,21 @@ http:
|
||||
customResponseHeaders:
|
||||
Cross-Origin-Opener-Policy: "same-origin"
|
||||
Cross-Origin-Embedder-Policy: "require-corp"
|
||||
|
||||
# Invidious
|
||||
http:
|
||||
routers:
|
||||
ggpht-router:
|
||||
rule: "PathPrefix(`/ggpht/`)"
|
||||
service: ggpht-service
|
||||
middlewares:
|
||||
- headers-ggpht
|
||||
|
||||
middlewares:
|
||||
headers-ggpht:
|
||||
headers:
|
||||
customResponseHeaders:
|
||||
Cross-Origin-Resource-Policy: cross-origin
|
||||
```
|
||||
|
||||
|
||||
|
||||
@@ -7,8 +7,8 @@ android {
|
||||
applicationId "us.materialio.app"
|
||||
minSdkVersion rootProject.ext.minSdkVersion
|
||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||
versionCode 36
|
||||
versionName "1.3.7"
|
||||
versionCode 37
|
||||
versionName "1.4.0"
|
||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||
aaptOptions {
|
||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "Materialious",
|
||||
"version": "1.3.7",
|
||||
"version": "1.4.0",
|
||||
"description": "Modern material design for Invidious.",
|
||||
"author": {
|
||||
"name": "Ward Pearce",
|
||||
@@ -27,7 +27,8 @@
|
||||
"electron-unhandled": "^4.0.1",
|
||||
"electron-updater": "^6.3.4",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"jsonfile": "^6.1.0"
|
||||
"jsonfile": "^6.1.0",
|
||||
"youtube-po-token-generator": "^0.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"electron": "^32.1.0",
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
require('./rt/electron-rt');
|
||||
//////////////////////////////
|
||||
// User Defined Preload scripts below
|
||||
console.log('User Preload!');
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
contextBridge.exposeInMainWorld('electron', {
|
||||
generatePoToken: () => ipcRenderer.invoke('generate-po-token')
|
||||
});
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
} from '@capacitor-community/electron';
|
||||
import chokidar from 'chokidar';
|
||||
import type { MenuItemConstructorOptions } from 'electron';
|
||||
import { app, BrowserWindow, Menu, MenuItem, nativeImage, session, Tray } from 'electron';
|
||||
import { app, BrowserWindow, ipcMain, Menu, MenuItem, nativeImage, session, Tray } from 'electron';
|
||||
import electronIsDev from 'electron-is-dev';
|
||||
import electronServe from 'electron-serve';
|
||||
import windowStateKeeper from 'electron-window-state';
|
||||
import { join } from 'path';
|
||||
import { generate } from 'youtube-po-token-generator';
|
||||
|
||||
// Define components for a watcher to detect when the webapp is changed so we can reload in Dev mode.
|
||||
const reloadWatcher = {
|
||||
@@ -214,6 +215,15 @@ export class ElectronCapacitorApp {
|
||||
CapElectronEventEmitter.emit('CAPELECTRON_DeeplinkListenerInitialized', '');
|
||||
}, 400);
|
||||
});
|
||||
|
||||
ipcMain.handle('generate-po-token', async () => {
|
||||
try {
|
||||
return await generate();
|
||||
} catch (error) {
|
||||
console.error('Error generating token:', error);
|
||||
return { error: error.message }; // Return error to renderer
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+2632
-7
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "materialious",
|
||||
"version": "1.3.7",
|
||||
"version": "1.4.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
@@ -26,16 +26,20 @@
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.0",
|
||||
"@typescript-eslint/parser": "^7.18.0",
|
||||
"@vite-pwa/sveltekit": "^0.6.5",
|
||||
"buffer": "^6.0.3",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-svelte": "^2.44.0",
|
||||
"i": "^0.3.7",
|
||||
"npm": "^10.8.3",
|
||||
"prettier": "^3.3.3",
|
||||
"prettier-plugin-svelte": "^3.2.6",
|
||||
"svelte": "^4.2.19",
|
||||
"svelte-check": "^3.8.6",
|
||||
"tslib": "^2.7.0",
|
||||
"typescript": "^5.5.4",
|
||||
"vite": "^5.4.5"
|
||||
"vite": "^5.4.5",
|
||||
"youtubei.js": "^10.4.0"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
@@ -62,4 +66,4 @@
|
||||
"svelte-persisted-store": "^0.11.0",
|
||||
"vidstack": "^1.12.9"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { patchYoutubeJs } from '$lib/patches/youtubejs';
|
||||
import { Capacitor } from '@capacitor/core';
|
||||
import { get } from 'svelte/store';
|
||||
import {
|
||||
authStore,
|
||||
@@ -5,6 +7,7 @@ import {
|
||||
deArrowThumbnailInstanceStore,
|
||||
instanceStore,
|
||||
interfaceRegionStore,
|
||||
playerYouTubeJsFallback,
|
||||
returnYTDislikesInstanceStore,
|
||||
synciousInstanceStore
|
||||
} from '../store';
|
||||
@@ -68,7 +71,12 @@ export async function getPopular(): Promise<Video[]> {
|
||||
}
|
||||
|
||||
export async function getVideo(videoId: string, local: boolean = false): Promise<VideoPlay> {
|
||||
const resp = await fetchErrorHandle(await fetch(setRegion(buildPath(`videos/${videoId}?local=${local}`))));
|
||||
const resp = await fetch(setRegion(buildPath(`videos/${videoId}?local=${local}`)));
|
||||
if (!resp.ok && Capacitor.getPlatform() === 'electron' && get(playerYouTubeJsFallback)) {
|
||||
return await patchYoutubeJs(videoId);
|
||||
} else {
|
||||
await fetchErrorHandle(resp);
|
||||
}
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,11 +6,15 @@
|
||||
|
||||
export let channel: Channel;
|
||||
|
||||
let channelPfp: string;
|
||||
let channelPfp: HTMLImageElement | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
const pfpResp = await fetch(proxyGoogleImage(getBestThumbnail(channel.authorThumbnails)));
|
||||
channelPfp = URL.createObjectURL(await pfpResp.blob());
|
||||
const img = new Image();
|
||||
img.src = proxyGoogleImage(getBestThumbnail(channel.authorThumbnails));
|
||||
|
||||
img.onload = () => {
|
||||
channelPfp = img;
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -23,7 +27,7 @@
|
||||
<img
|
||||
class="circle"
|
||||
style="width: 90px;height: 90px;"
|
||||
src={channelPfp}
|
||||
src={channelPfp.src}
|
||||
alt={channel.author}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getComments } from './Api';
|
||||
import { type Comment, type Comments } from './Api/model';
|
||||
import { getBestThumbnail, loadPfp, numberWithCommas, proxyGoogleImage } from './misc';
|
||||
import { getBestThumbnail, numberWithCommas, proxyGoogleImage } from './misc';
|
||||
|
||||
export let comment: Comment;
|
||||
export let videoId: string;
|
||||
@@ -31,9 +31,12 @@
|
||||
</script>
|
||||
|
||||
<div class="comment">
|
||||
{#await loadPfp(proxyGoogleImage(getBestThumbnail(comment.authorThumbnails))) then pfp}
|
||||
<img class="circle small" src={pfp} alt="comment profile" />
|
||||
{/await}
|
||||
<img
|
||||
class="circle small"
|
||||
src={proxyGoogleImage(getBestThumbnail(comment.authorThumbnails))}
|
||||
alt="comment profile"
|
||||
/>
|
||||
|
||||
<div>
|
||||
<div class="row">
|
||||
<a href={`/channel/${comment.authorId}`}>
|
||||
@@ -58,9 +61,12 @@
|
||||
<p><i>thumb_up</i> {numberWithCommas(comment.likeCount)}</p>
|
||||
{#if comment.creatorHeart}
|
||||
<div>
|
||||
{#await loadPfp(proxyGoogleImage(comment.creatorHeart.creatorThumbnail)) then pfp}
|
||||
<img class="circle" style="width: 25px; height: 25px" src={pfp} alt="Creator profile" />
|
||||
{/await}
|
||||
<img
|
||||
class="circle"
|
||||
style="width: 25px; height: 25px"
|
||||
src={proxyGoogleImage(comment.creatorHeart.creatorThumbnail)}
|
||||
alt="Creator profile"
|
||||
/>
|
||||
<i style="font-size: 20px;margin-left: 5px;" class="absolute left red-text bottom fill"
|
||||
>favorite</i
|
||||
>
|
||||
|
||||
@@ -193,7 +193,10 @@
|
||||
label: caption.label,
|
||||
kind: 'captions',
|
||||
language: caption.language_code,
|
||||
src: `${get(instanceStore)}${caption.url}`
|
||||
// Need if captions are generated when youtube.js is being used.
|
||||
src: caption.url.startsWith('blob:')
|
||||
? caption.url
|
||||
: `${get(instanceStore)}${caption.url}`
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
playerProxyVideosStore,
|
||||
playerSavePlaybackPositionStore,
|
||||
playerTheatreModeByDefaultStore,
|
||||
playerYouTubeJsFallback,
|
||||
returnYTDislikesInstanceStore,
|
||||
returnYtDislikesStore,
|
||||
silenceSkipperStore,
|
||||
@@ -130,8 +131,8 @@
|
||||
>
|
||||
<nav>
|
||||
<div class="field label border max">
|
||||
<input bind:value={invidiousInstance} name="returnyt-instance" type="text" />
|
||||
<label for="returnyt-instance">{$_('layout.instanceUrl')}</label>
|
||||
<input bind:value={invidiousInstance} name="invidious-instance" type="text" />
|
||||
<label for="invidious-instance">{$_('layout.instanceUrl')}</label>
|
||||
</div>
|
||||
<button class="square round">
|
||||
<i>done</i>
|
||||
@@ -439,6 +440,24 @@
|
||||
</label>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
{#if Capacitor.getPlatform() === 'electron'}
|
||||
<div class="field no-margin">
|
||||
<nav class="no-padding">
|
||||
<div class="max">
|
||||
<div>{$_('layout.player.youtubeJsFallback')}</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={$playerYouTubeJsFallback}
|
||||
on:click={() => playerYouTubeJsFallback.set(!$playerYouTubeJsFallback)}
|
||||
/>
|
||||
<span></span>
|
||||
</label>
|
||||
</nav>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="settings">
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
let videoPreviewVolume: number = 0.4;
|
||||
let imgHeight: number;
|
||||
|
||||
let authorImg: string = '';
|
||||
let authorImg: HTMLImageElement | undefined;
|
||||
|
||||
let proxyVideos = get(playerProxyVideosStore);
|
||||
|
||||
@@ -85,10 +85,12 @@
|
||||
|
||||
async function loadAuthor() {
|
||||
const channel = await getChannel(video.authorId);
|
||||
const imgResp = await fetch(
|
||||
proxyGoogleImage(getBestThumbnail(channel.authorThumbnails, 75, 75))
|
||||
);
|
||||
authorImg = URL.createObjectURL(await imgResp.blob());
|
||||
const img = new Image();
|
||||
img.src = proxyGoogleImage(getBestThumbnail(channel.authorThumbnails, 75, 75));
|
||||
|
||||
img.onload = () => {
|
||||
authorImg = img;
|
||||
};
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
@@ -228,6 +230,10 @@
|
||||
showVideoPreview = true;
|
||||
try {
|
||||
videoPreview = await getVideo(video.videoId);
|
||||
if (videoPreview.formatStreams.length === 0) {
|
||||
showVideoPreview = false;
|
||||
}
|
||||
|
||||
if (videoPreview.hlsUrl) {
|
||||
showVideoPreview = false;
|
||||
videoPreview = null;
|
||||
@@ -353,8 +359,8 @@
|
||||
<div class="thumbnail-details video-title">
|
||||
{#if !sideways}
|
||||
<div style="margin-right: 1em;">
|
||||
{#if authorImg !== ''}
|
||||
<img class="circle small" src={authorImg} alt="Author" />
|
||||
{#if authorImg}
|
||||
<img src={authorImg.src} alt="Author" class="circle small" />
|
||||
{:else}
|
||||
<progress style="padding: 15px;" class="circle small"></progress>
|
||||
{/if}
|
||||
|
||||
@@ -123,7 +123,8 @@
|
||||
"proxyVideos": "Proxy videos",
|
||||
"backgroundPlay": "Play audio in background",
|
||||
"dash": "Dash",
|
||||
"silenceSkipper": "Silence skipper (Experimental)"
|
||||
"silenceSkipper": "Silence skipper (Experimental)",
|
||||
"youtubeJsFallback": "Locally video processing fallback"
|
||||
},
|
||||
"bookmarklet": "Bookmarklet",
|
||||
"instanceUrl": "Instance URL",
|
||||
|
||||
@@ -170,11 +170,6 @@ export function proxyVideoUrl(source: string): string {
|
||||
return rawSrc.toString();
|
||||
}
|
||||
|
||||
export async function loadPfp(url: string): Promise<string> {
|
||||
const resp = await fetch(url);
|
||||
return URL.createObjectURL(await resp.blob());
|
||||
}
|
||||
|
||||
export function pullBitratePreference(): number {
|
||||
const vidstack = localStorage.getItem('video-player');
|
||||
|
||||
@@ -239,15 +234,15 @@ export function getBestThumbnail(
|
||||
maxHeightDimension = 360
|
||||
): string {
|
||||
if (images && images.length > 0) {
|
||||
images = images.filter(
|
||||
const imagesFiltered = images.filter(
|
||||
(image) => image.width < maxWidthDimension && image.height < maxHeightDimension
|
||||
);
|
||||
|
||||
if (images.length === 0) {
|
||||
return '';
|
||||
if (imagesFiltered.length === 0) {
|
||||
return images[0].url;
|
||||
}
|
||||
|
||||
images.sort((a, b) => {
|
||||
imagesFiltered.sort((a, b) => {
|
||||
return b.width * b.height - a.width * a.height;
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import type { Image, Thumbnail, VideoBase, VideoPlay } from '$lib/Api/model';
|
||||
import { numberWithCommas } from '$lib/misc';
|
||||
|
||||
export async function patchYoutubeJs(videoId: string): Promise<VideoPlay> {
|
||||
const innertube = (await import('youtubei.js')).Innertube;
|
||||
|
||||
const tokens = await (window as any).electron.generatePoToken();
|
||||
|
||||
const youtube = await innertube.create({
|
||||
visitor_data: tokens.visitorData,
|
||||
po_token: tokens.poToken
|
||||
});
|
||||
|
||||
const video = await youtube.getInfo(videoId);
|
||||
|
||||
if (!video.primary_info || !video.secondary_info) {
|
||||
throw new Error('Unable to pull video info from youtube.js');
|
||||
}
|
||||
|
||||
let manifest = await video.toDash();
|
||||
|
||||
// Hack to fix video not displaying.
|
||||
// Thanks to absidue & Andrews54757
|
||||
manifest = manifest.replaceAll('<EssentialProperty', '<SupplementalProperty');
|
||||
const dashUri = URL.createObjectURL(new Blob([manifest], { type: 'application/dash+xml;charset=utf8' }));
|
||||
|
||||
const descString = video.secondary_info.description.toString();
|
||||
|
||||
let authorThumbnails: Image[];
|
||||
if (video.basic_info.channel_id) {
|
||||
authorThumbnails = (await youtube.getChannel(video.basic_info.channel_id)).metadata.avatar as Image[];
|
||||
} else {
|
||||
authorThumbnails = [];
|
||||
}
|
||||
|
||||
let recommendedVideos: VideoBase[] = [];
|
||||
video.watch_next_feed?.forEach((recommended: Record<string, any>) => {
|
||||
recommendedVideos.push({
|
||||
videoThumbnails: recommended?.thumbnails as Thumbnail[] || [],
|
||||
videoId: recommended?.id || '',
|
||||
title: recommended?.title.toString() || '',
|
||||
viewCountText: numberWithCommas(Number(recommended?.view_count.toString().replace(/\D/g, '') || 0)) || '',
|
||||
lengthSeconds: recommended?.duration?.seconds || 0,
|
||||
author: recommended?.author.name || '',
|
||||
authorId: recommended?.author.id || ''
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
type: 'video',
|
||||
title: video.primary_info.title.toString(),
|
||||
viewCount: Number(video.primary_info.view_count.toString().replace(/\D/g, '')),
|
||||
viewCountText: video.primary_info.view_count.toString(),
|
||||
likeCount: video.basic_info.like_count || 0,
|
||||
dislikeCount: 0,
|
||||
allowRatings: false,
|
||||
rating: 0,
|
||||
isListed: 0,
|
||||
isFamilyFriendly: video.basic_info.is_family_safe || true,
|
||||
genre: video.basic_info.category || '',
|
||||
genreUrl: '',
|
||||
dashUrl: dashUri,
|
||||
adaptiveFormats: [],
|
||||
formatStreams: [],
|
||||
recommendedVideos: recommendedVideos,
|
||||
authorThumbnails: authorThumbnails,
|
||||
captions: [],
|
||||
authorId: video.basic_info.channel_id || '',
|
||||
authorUrl: `/channel/${video.basic_info.channel_id}`,
|
||||
authorVerified: false,
|
||||
description: descString,
|
||||
descriptionHtml: video.secondary_info.description.toHTML() || descString,
|
||||
published: 0,
|
||||
publishedText: video.primary_info.published.toString(),
|
||||
premiereTimestamp: 0,
|
||||
liveNow: false,
|
||||
premium: false,
|
||||
isUpcoming: false,
|
||||
videoId: videoId,
|
||||
videoThumbnails: video.basic_info.thumbnail as Thumbnail[],
|
||||
author: video.basic_info.author || 'Unknown',
|
||||
lengthSeconds: video.basic_info.duration || 0,
|
||||
subCountText: '',
|
||||
keywords: video.basic_info.keywords || [],
|
||||
allowedRegions: []
|
||||
};
|
||||
}
|
||||
@@ -39,6 +39,7 @@ export const playerTheatreModeByDefaultStore = persisted('theatreModeByDefault',
|
||||
export const playerAutoplayNextByDefaultStore = persisted('autoplayNextByDefault', false);
|
||||
export const playerMiniPlayerStore = persisted('miniPlayer', true);
|
||||
export const playerAndroidBackgroundPlayStore = persisted('androidBackgroundPlayer', true);
|
||||
export const playerYouTubeJsFallback = persisted('youTubeJsFallback', true);
|
||||
|
||||
export const returnYtDislikesStore = persisted('returnYtDislikes', false);
|
||||
export const returnYTDislikesInstanceStore: Writable<string | null | undefined> = persisted(
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { get } from 'svelte/store';
|
||||
import type { MediaPlayerElement } from 'vidstack/elements';
|
||||
import { loadPfp } from '../../../../lib/misc.js';
|
||||
|
||||
export let data;
|
||||
|
||||
@@ -487,9 +486,11 @@
|
||||
<nav>
|
||||
<a href={`/channel/${data.video.authorId}`}>
|
||||
<nav>
|
||||
{#await loadPfp(proxyGoogleImage(getBestThumbnail(data.video.authorThumbnails))) then pfp}
|
||||
<img class="circle large" src={pfp} alt="Channel profile" />
|
||||
{/await}
|
||||
<img
|
||||
class="circle large"
|
||||
src={proxyGoogleImage(getBestThumbnail(data.video.authorThumbnails))}
|
||||
alt="Channel profile"
|
||||
/>
|
||||
<div>
|
||||
<p style="margin: 0;" class="bold">{data.video.author}</p>
|
||||
<p style="margin: 0;">{data.video.subCountText}</p>
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
|
||||
LATEST_VERSION = "1.3.7"
|
||||
LATEST_VERSION = "1.4.0"
|
||||
WORKING_DIR = os.path.join(os.path.dirname(os.path.realpath(__file__)), "materialious")
|
||||
|
||||
ROOT_PACKAGE = os.path.join(WORKING_DIR, "package.json")
|
||||
|
||||
Reference in New Issue
Block a user