mirror of
https://codeberg.org/Hyperpipe/Hyperpipe
synced 2025-06-28 13:08:01 +02:00
parent
d673c0d998
commit
d4eee69077
11 changed files with 30 additions and 29 deletions
585
src/components/Library.vue
Normal file
585
src/components/Library.vue
Normal file
|
@ -0,0 +1,585 @@
|
|||
<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="t('title.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', '?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>
|
Loading…
Add table
Add a link
Reference in a new issue