Hyperpipe/src/components/NewPlaylist.vue
2023-07-19 10:38:18 +02:00

582 lines
13 KiB
Vue

<script setup>
import { ref, reactive, watch, onMounted } from 'vue';
import AlbumItem from './AlbumItem.vue';
import Modal from './Modal.vue';
import { useRand, parseThumb } from '@/scripts/colors.js';
import { useStore } from '@/scripts/util.js';
import {
useListPlaylists,
useGetPlaylist,
useCreatePlaylist,
useUpdatePlaylist,
} from '@/scripts/db.js';
import {
useAuthLogout,
useAuthCreatePlaylist,
getAuthPlaylists,
getJsonAuth,
getJsonPiped,
} from '@/scripts/fetch.js';
import { useResults } from '@/stores/results.js';
import { useI18n, useNav } from '@/stores/misc.js';
const { t } = useI18n(),
store = useStore(),
auth = ref(!!store.auth),
results = useResults(),
nav = useNav();
defineEmits(['play-urls', 'open-playlist']);
const list = ref([]),
show = reactive({
new: false,
sync: false,
import: false,
}),
text = ref(''),
isImport = ref(false),
importFile = ref(''),
sync = reactive({
type: 'send',
id: 'Please Wait...',
to: '',
peer: undefined,
}),
user = reactive({
username: undefined,
password: undefined,
playlists: [],
create: false,
}),
proxy = ref('');
const pathname = url => new URL(url).pathname;
const setProxy = async () => {
const { imageProxyUrl } = await getJsonPiped('/config');
proxy.value = imageProxyUrl;
},
Open = async key => {
console.log(key);
if (!proxy.value) await setProxy();
useGetPlaylist(key, res => {
console.log(res);
if (res.urls.length > 0) {
results.resetItems();
results.setItem('songs', {
title: 'Local • ' + key,
items: res.urls.map(i => ({
...i,
playlistId: key,
thumbnail: parseThumb(i.url, proxy.value),
})),
});
nav.state.page = 'home';
} else alert('No songs to play!');
});
},
OpenOffline = async () => {
if (window.offline) {
const songs = await window.offline.list();
console.log();
results.resetItems();
results.setItem('songs', {
title: 'Hyp • Offline',
items: songs.map(i => ({
...i.appMetadata,
offlineUri: i.offlineUri,
thumbnail: parseThumb(i.appMetadata.url, proxy.value),
duration: i.duration,
})),
});
nav.state.page = 'home';
}
},
List = async () => {
if (!proxy.value) await setProxy();
useListPlaylists(res => {
list.value = res;
});
},
Create = () => {
if (text.value) {
useCreatePlaylist(text.value, [], async () => {
show.new = false;
await List();
});
}
},
Import = async (data = importFile.value) => {
if (data?.type == 'application/json')
data = await data.text().then(json => {
json = JSON.parse(json);
if (json?.subscriptions?.length > 0) {
const subs = JSON.parse(store.subs || '[]');
for (const sub of json.subscriptions) {
const id = sub.url.slice(-24);
if (subs.indexOf(id) < 0) subs.push(id);
}
store.subs = JSON.stringify(subs);
}
return json.local;
});
if (!data) {
alert('No data to import');
return;
}
await List();
for (let i of data) {
const pl = list.value.find(p => p.name == i.name);
if (pl) {
for (let u of i.urls) {
if (pl.urls.findIndex(r => r.url === u.url) < 0) {
useUpdatePlaylist(i.name, u, () => {
console.log('Added: ' + u.name);
});
}
}
} else useCreatePlaylist(i.name, i.urls);
await List();
}
show.import = false;
},
Export = async () => {
await List();
const base = JSON.stringify(
{
format: 'Hyperpipe',
version: 0,
app_version: 0,
subscriptions: JSON.parse(store.subs).map(id => ({
url: 'https://www.youtube.com/channel/' + id,
service_id: 0,
})),
local: list.value,
playlists: [], // TODO?
},
null,
2,
),
file = new Blob([base], { type: 'application/json' }),
ele = document.createElement('a');
ele.href = URL.createObjectURL(file);
ele.download = 'hyperpipe.json';
ele.click();
ele.remove();
},
Send = () => {
const conn = sync.peer.connect(sync.to);
console.log(conn);
conn.on('open', async () => {
await List();
conn.send(list.value);
});
conn.on('close', () => {
show.sync = false;
});
conn.on('error', err => {
console.log(err);
});
};
const Login = async () => {
if (user.username && user.password) {
const res = await getJsonAuth('/login', {
method: 'POST',
body: JSON.stringify({
username: user.username,
password: user.password,
}),
});
if (!res.error) {
store.setItem('auth', res.token);
auth.value = true;
} else alert(res.error);
}
},
Logout = async () => {
const res = await useAuthLogout();
if (!res.error) {
store.removeItem('auth');
auth.value = false;
} else alert(res.error);
},
getPlaylists = async () => {
const res = await getAuthPlaylists();
user.playlists = res;
console.log(user.playlists);
},
createPlaylist = async () => {
if (text.value) {
await useAuthCreatePlaylist(text.value);
getPlaylists();
show.new = false;
}
};
const getFeeds = async () => {
const subs = store.subs;
if (subs) {
const json = await getJsonPiped('/feed/unauthenticated', {
method: 'POST',
body: subs,
});
results.resetItems();
results.setItem('songs', {
title: t('title.feeds'),
items: json,
});
nav.state.page = 'home';
}
};
watch(
() => show.sync,
async () => {
const { Peer } = await import('peerjs');
window.Peer = Peer;
if (show.sync === true) {
sync.peer = new Peer('hyp-' + Math.random().toString(36).substr(2));
sync.peer.on('open', id => {
sync.id = id;
});
sync.peer.on('connection', conn => {
console.log(conn);
conn.on('data', data => {
if (sync.type == 'rec') {
console.log(data);
Import(data);
show.sync = false;
}
});
});
} else if (sync.peer) {
sync.peer.destroy();
}
},
);
watch(auth, getPlaylists);
onMounted(async () => {
await List();
await getPlaylists();
});
</script>
<template>
<div class="npl-wrap">
<Modal
n="2"
:display="show.new"
:title="t('playlist.create')"
@show="
e => {
show.new = e;
}
">
<template #content>
<div v-if="auth" class="tabs">
<button :data-active="!user.create" @click="user.create = false">
{{ t('title.local') }}
</button>
<button :data-active="user.create" @click="user.create = true">
{{ t('title.remote') }}
</button>
</div>
<input
type="text"
:placeholder="t('playlist.name') + '...'"
class="textbox"
v-model="text" />
</template>
<template #buttons>
<button @click="show.new = false">{{ t('action.cancel') }}</button>
<button @click="user.create ? createPlaylist() : Create()">
{{ t('action.create') }}
</button>
</template>
</Modal>
<Modal
:n="sync.type == 'send' ? 2 : 1"
:display="show.sync"
:title="t('playlist.sync')"
@show="
e => {
show.sync = e;
}
">
<template #content>
<div class="tabs">
<button
:data-active="sync.type == 'send'"
@click="sync.type = 'send'">
{{ t('action.send') }}
</button>
<button :data-active="sync.type == 'rec'" @click="sync.type = 'rec'">
{{ t('action.receive') }}
</button>
</div>
<div v-if="sync.type == 'send'">
<input
type="text"
class="textbox"
placeholder="ID ( hyp-xxxxxxxxx )"
@input="sync.to = $event.target.value" />
</div>
<div v-else-if="sync.type == 'rec'">
<pre>ID: {{ sync.id }}</pre>
</div>
</template>
<template #buttons>
<button @click="show.sync = false">{{ t('action.cancel') }}</button>
<button v-if="sync.type == 'send'" @click="Send">
{{ sync.type == 'send' ? t('action.send') : t('action.recieve') }}
</button>
</template>
</Modal>
<Modal
n="2"
:display="show.import"
:title="t('action.import')"
@show="
e => {
show.import = e;
}
">
<template #content>
<div class="tabs">
<button :data-active="isImport" @click="isImport = true">
{{ t('action.import') }}
</button>
<button :data-active="!isImport" @click="isImport = false">
{{ t('action.export') }}
</button>
</div>
<div v-if="isImport">
<input
type="file"
class="textbox"
name="import"
accept="application/json"
@change="importFile = $event.target.files[0]" />
</div>
</template>
<template #buttons>
<button @click="show.import = false">{{ t('action.cancel') }}</button>
<button @click="isImport ? Import() : Export()">
{{ isImport ? t('action.import') : t('action.export') }}
</button>
</template>
</Modal>
<div class="grid">
<div class="npl-box bi bi-plus-lg pop" @click="show.new = true"></div>
<div
class="npl-box bi bi-arrow-repeat pop"
@click="show.sync = true"></div>
<div class="npl-box bi bi-tag pop" @click="getFeeds"></div>
<div
class="npl-box bi bi-box-arrow-in-down pop"
@click="show.import = true"></div>
</div>
<h2 v-if="list.length > 0">{{ t('playlist.local') }}</h2>
<div class="grid-3">
<AlbumItem name="Offline" :grad="useRand()" @open-album="OpenOffline()" />
<AlbumItem
v-for="i in list"
:key="i.name"
:name="i.name"
:author="t('title.songs') + ' • ' + i.urls.length"
:art="parseThumb(i.urls[0]?.url, proxy)"
:grad="useRand()"
@open-album="Open(i.name)" />
</div>
<h2 class="login-h">{{ t('playlist.remote') }}</h2>
<div v-if="auth" class="grid-3">
<AlbumItem
v-for="i in user.playlists"
:key="i.id"
:name="i.name.replace('Playlist - ', '')"
:author="t('title.songs') + ' • ' + i.videos"
:art="pathname(i.thumbnail) != '/' ? i.thumbnail : undefined"
@open-album="$emit('open-playlist', '/playlist?list=' + i.id)" />
</div>
<form v-else class="login" @submit.prevent>
<input
class="textbox"
type="text"
:placeholder="t('general.username')"
autocomplete="username"
@change="user.username = $event.target.value"
required />
<input
class="textbox"
type="password"
:placeholder="t('general.password')"
autocomplete="password"
@change="user.password = $event.target.value"
required />
<button @click="Login" class="textbox">{{ t('title.login') }}</button>
<p>
{{ t('action.register_on') }}
<a
href="https://piped.video/register"
target="_blank"
rel="noreferrer noopener"
> Piped</a
>
</p>
</form>
<button v-if="auth" @click="Logout" class="logout textbox">Logout</button>
</div>
</template>
<style scoped>
.grid {
display: grid;
width: fit-content;
gap: 1rem;
grid-auto-rows: 10rem;
margin: 0 auto;
}
h2 {
text-align: center;
margin: 2rem;
}
.npl-wrap {
padding-bottom: 5rem;
}
.npl-box {
--background: var(--color-background-mute);
border-radius: 0.5rem;
padding: 2rem 3rem;
font-size: 4rem;
width: 10rem;
}
.npl-round {
float: left;
display: inline-block;
height: 5rem;
width: 5rem;
border-radius: 50%;
background: v-bind('bg');
}
.text-box {
padding: 2rem;
}
pre {
white-space: pre-wrap;
}
.tabs {
margin: 0.5rem 0 1.5rem 0;
}
.tabs button {
width: calc(100% / 2);
background: var(--color-background);
}
.tabs button[data-active='true'],
.login button,
button.logout {
font-weight: bold;
color: var(--color-background);
background: linear-gradient(135deg, cornflowerblue, #88c0d0);
}
button.logout {
margin: 1rem auto;
display: block;
background: linear-gradient(135deg, indianred, #bf616a);
}
input[type='file'] {
display: block;
}
input[type='file']::file-selector-button {
font-weight: bold;
appearance: inherit;
outline: inherit;
border: inherit;
border-radius: 0.25rem;
padding: 0.5rem;
margin: 0.1rem 0.5rem 0.1rem 0.1rem;
color: var(--color-background);
background: linear-gradient(135deg, cornflowerblue, #88c0d0);
}
.tabs button:first-child {
border-radius: 0.25rem 0 0 0.25rem;
}
.tabs button:last-child {
border-radius: 0 0.25rem 0.25rem 0;
}
.login {
display: block;
margin: 1rem auto;
}
.login > * {
margin: 1rem auto;
display: block;
text-align: center;
}
@media (min-width: 400px) {
.grid {
grid-template-columns: 1fr 1fr;
}
}
</style>