Add torrent and zip viewers to filesystem

This commit is contained in:
2023-05-19 17:17:05 +02:00
parent 84f835f581
commit 98c725f291
12 changed files with 559 additions and 97 deletions

View File

@@ -4,7 +4,7 @@ import { formatDataVolume, formatDate } from "../../util/Formatting.svelte"
import IconBlock from "./IconBlock.svelte";
import TextBlock from "./TextBlock.svelte";
import ZipItem from "./ZipItem.svelte";
import BandwidthUsage from "./BandwidthUsage.svelte";
import BandwidthUsage from "./BandwidthUsage.svelte";
let dispatch = createEventDispatcher()

View File

@@ -8,7 +8,7 @@ export let visible = false
export const toggle = () => {visible = !visible}
</script>
<Modal bind:visible={visible} title="Details" width="800px" role="prompt">
<Modal bind:visible={visible} title="Details" width="800px">
<table style="min-width: 100%;">
<tr><td colspan="2"><h3>Node details</h3></td></tr>
<tr><td>Name</td><td>{state.base.name}</td></tr>

View File

@@ -10,7 +10,9 @@ import DetailsWindow from './DetailsWindow.svelte';
import Navigator from './Navigator.svelte';
import FilePreview from './viewers/FilePreview.svelte';
let loading = true
let toolbar_visible = (window.innerWidth > 600)
let file_preview
let download_frame
let details_visible = false
let edit_window
@@ -32,20 +34,9 @@ let state = {
read_password: "",
write_password: "",
// These are used to navigate forward and backward within a directory (using
// the previous and next buttons on the toolbar). The cached siblings will
// be used so that we don't need to make an extra request to the parent
// directory. The siblings_path variable is used to verify that the parent
// directory is still the same. If it's sifferent the siblings array is not
// used
siblings_path: "",
siblings: null,
// Root path of the bucket. Used for navigation by prepending it to a file
// path
path_root: "/d/"+window.initial_node.path[0].id,
loading: true,
viewer_type: "",
shuffle: false,
}
@@ -94,9 +85,14 @@ const download = () => {
<svelte:window on:keydown={keydown} />
<LoadingIndicator loading={state.loading}/>
<LoadingIndicator loading={loading}/>
<Navigator bind:this={fs_navigator} bind:state/>
<Navigator
bind:this={fs_navigator}
bind:state
on:navigation_complete={() => file_preview.state_update()}
on:loading={e => loading = e.detail}
/>
<div class="file_viewer">
<div class="headerbar">
@@ -127,11 +123,12 @@ const download = () => {
/>
<FilePreview
bind:this={file_preview}
fs_navigator={fs_navigator}
state={state}
toolbar_visible={toolbar_visible}
edit_window={edit_window}
on:loading={e => {state.loading = e.detail}}
on:loading={e => {loading = e.detail}}
on:open_sibling={e => fs_navigator.open_sibling(e.detail)}
on:download={download}
/>

View File

@@ -1,6 +1,9 @@
<script>
import { createEventDispatcher } from "svelte";
import { fs_get_node } from "./FilesystemAPI";
import { fs_node_type, fs_split_path } from "./FilesystemUtil";
import { fs_split_path } from "./FilesystemUtil";
let dispatch = createEventDispatcher()
export let state = {
// Parts of the raw API response
@@ -20,25 +23,14 @@ export let state = {
read_password: "",
write_password: "",
// These are used to navigate forward and backward within a directory (using
// the previous and next buttons on the toolbar). The cached siblings will
// be used so that we don't need to make an extra request to the parent
// directory. The siblings_path variable is used to verify that the parent
// directory is still the same. If it's sifferent the siblings array is not
// used
siblings_path: "",
siblings: null,
// Root path of the bucket. Used for navigation by prepending it to a file
// path
path_root: "",
loading: false,
viewer_type: "",
shuffle: false,
}
export const navigate = async (path, push_history) => {
state.loading = true
dispatch("loading", true)
console.debug("Navigating to path", path, push_history)
try {
@@ -57,7 +49,7 @@ export const navigate = async (path, push_history) => {
alert("Error: "+err)
}
} finally {
state.loading = false
dispatch("loading", false)
}
}
@@ -87,11 +79,11 @@ export const open_node = (node, push_history) => {
// If the new node is a child of the previous node we save the parent's
// children array
if (node.path.length > 0 && node.path[node.path.length-1].path === state.base.path) {
if (node.path.length > 1 && node.path[node.path.length-2].path === state.base.path) {
console.debug("Current parent path and new node path match. Saving siblings")
state.siblings_path = node.path[node.path.length-1].path
state.siblings = state.children
siblings_path = node.path[node.path.length-1].path
siblings = state.children
}
// Sort directory children
@@ -105,15 +97,27 @@ export const open_node = (node, push_history) => {
state.children = node.children
state.permissions = node.permissions
// Update the viewer area with the right viewer type
state.viewer_type = fs_node_type(state.base)
console.debug("Opened node", node)
// Signal to parent that navigation is complete. Normally relying on
// reactivity is enough, but sometimes that can trigger double updates. By
// manually triggering an update we can be sure that updates happen exactly
// when we mean to
dispatch("navigation_complete")
// Remove spinner
state.loading = false
dispatch("loading", false)
}
// These are used to navigate forward and backward within a directory (using
// the previous and next buttons on the toolbar). The cached siblings will
// be used so that we don't need to make an extra request to the parent
// directory. The siblings_path variable is used to verify that the parent
// directory is still the same. If it's sifferent the siblings array is not
// used
let siblings_path = ""
let siblings = null
// Opens a sibling of the currently open file. The offset is relative to the
// file which is currently open. Give a positive number to move forward and a
// negative number to move backward
@@ -122,11 +126,11 @@ export const open_sibling = async offset => {
return
}
state.loading = true
dispatch("loading", true)
// Check if we already have siblings cached
if (state.siblings != null && state.siblings_path == state.path[state.path.length - 2].path) {
console.debug("Using cached siblings")
if (siblings != null && siblings_path == state.path[state.path.length - 2].path) {
console.debug("Using cached siblings", siblings)
} else {
console.debug("Cached siblings not available. Fetching new")
try {
@@ -135,13 +139,13 @@ export const open_sibling = async offset => {
// Sort directory children to make sure the order is consistent
sort_children(resp.children)
// Save new siblings in global state
state.siblings_path = state.path[state.path.length - 2].path
state.siblings = resp.children
// Save new siblings in navigator state
siblings_path = state.path[state.path.length - 2].path
siblings = resp.children
} catch (err) {
console.error(err)
alert(err)
state.loading = false
dispatch("loading", false)
return
}
}
@@ -151,7 +155,7 @@ export const open_sibling = async offset => {
if (state.shuffle) {
// Shuffle is on, pick a random sibling
for (let i = 0; i < 10; i++) {
next_sibling = state.siblings[Math.floor(Math.random()*state.siblings.length)]
next_sibling = siblings[Math.floor(Math.random()*siblings.length)]
// If we selected the same sibling we already have open we try
// again. Else we break the loop
@@ -163,13 +167,13 @@ export const open_sibling = async offset => {
// Loop over the parent node's children to find the one which is
// currently open. Then, if possible, we save the one which comes before
// or after it
for (let i = 0; i < state.siblings.length; i++) {
for (let i = 0; i < siblings.length; i++) {
if (
state.siblings[i].name === state.base.name &&
siblings[i].name === state.base.name &&
i+offset >= 0 && // Prevent underflow
i+offset < state.siblings.length // Prevent overflow
i+offset < siblings.length // Prevent overflow
) {
next_sibling = state.siblings[i+offset]
next_sibling = siblings[i+offset]
break
}
}
@@ -177,11 +181,11 @@ export const open_sibling = async offset => {
// If we found a sibling we open it
if (next_sibling !== null) {
console.debug("Opening sibling", next_sibling)
console.debug("Opening sibling", next_sibling.path)
navigate(next_sibling.path, true)
} else {
console.debug("No siblings found")
state.loading = false
dispatch("loading", false)
}
}

View File

@@ -79,6 +79,7 @@ const delete_selected = () => {
// Wait for all the promises to finish
Promise.all(promises).catch((err) => {
console.error(err)
alert("Delete failed: ", err)
}).finally(() => {
mode = "viewing"
reload()

View File

@@ -1,4 +1,7 @@
<script>
import { tick } from "svelte";
import Spinner from "../../util/Spinner.svelte";
import { fs_node_type } from "../FilesystemUtil";
import FileManager from "../filemanager/FileManager.svelte";
import Audio from "./Audio.svelte";
import File from "./File.svelte";
@@ -6,31 +9,57 @@ import Image from "./Image.svelte";
import Pdf from "./PDF.svelte";
import Text from "./Text.svelte";
import Video from "./Video.svelte";
import Torrent from "./Torrent.svelte";
import Zip from "./Zip.svelte";
export let fs_navigator
export let state
export let toolbar_visible
export let edit_window
export let state
let viewer
let viewer_type = ""
export const state_update = async () => {
// Update the viewer area with the right viewer type
viewer_type = fs_node_type(state.base)
console.debug("Previewing file", state.base, "viewer type", viewer_type)
// Render the viewer component and set the file type
await tick()
if (viewer) {
viewer.update()
}
}
</script>
<div class="file_preview checkers" class:toolbar_visible>
{#if state.viewer_type === "dir"}
{#if viewer_type === ""}
<div class="center">
<Spinner></Spinner>
</div>
{:else if viewer_type === "dir"}
<FileManager
fs_navigator={fs_navigator}
state={state}
edit_window={edit_window}
on:loading
/>
{:else if state.viewer_type === "audio"}
{:else if viewer_type === "audio"}
<Audio state={state} on:open_sibling/>
{:else if state.viewer_type === "image"}
{:else if viewer_type === "image"}
<Image state={state} on:open_sibling/>
{:else if state.viewer_type === "video"}
<Video state={state} on:open_sibling/>
{:else if state.viewer_type === "pdf"}
{:else if viewer_type === "video"}
<Video state={state} bind:this={viewer} on:open_sibling/>
{:else if viewer_type === "pdf"}
<Pdf state={state}/>
{:else if state.viewer_type === "text"}
{:else if viewer_type === "text"}
<Text state={state}/>
{:else if viewer_type === "torrent"}
<Torrent state={state} bind:this={viewer} on:loading on:download/>
{:else if viewer_type === "zip"}
<Zip state={state} bind:this={viewer} on:loading on:download />
{:else}
<File state={state} on:download/>
{/if}
@@ -43,18 +72,29 @@ export let edit_window
right: 0;
top: 0;
bottom: 0;
display: inline-block;
display: block;
min-height: 100px;
min-width: 100px;
text-align: center;
vertical-align: middle;
transition: left 0.25s;
overflow: hidden;
border-radius: 12px;
overflow: auto;
text-align: center;
border-radius: 8px;
border: 2px solid var(--separator);
}
.file_preview.toolbar_visible {
left: 8em;
}
.center{
position: relative;
display: block;
margin: auto;
width: 100px;
max-width: 100%;
height: 100px;
max-height: 100%;
top: 50%;
transform: translateY(-50%);
}
</style>

View File

@@ -56,15 +56,15 @@ const mouseup = (e) => {
<style>
.container {
position: relative;
display: block;
display: flex;
justify-content: center;
height: 100%;
width: 100%;
text-align: center;
overflow: hidden;
}
.container.zoom {
overflow: auto;
justify-content: unset;
}
.image {
position: relative;
@@ -72,16 +72,11 @@ const mouseup = (e) => {
margin: auto;
max-width: 100%;
max-height: 100%;
top: 50%;
cursor: pointer;
transform: translateY(-50%);
box-shadow: 1px 1px 5px var(--shadow_color);
}
.image.zoom {
max-width: none;
max-height: none;
top: 0;
cursor: move;
transform: none;
}
</style>

View File

@@ -0,0 +1,136 @@
<script>
import { createEventDispatcher } from "svelte";
import Magnet from "../../icons/Magnet.svelte";
import { formatDate } from "../../util/Formatting.svelte"
import { copy_text } from "../../util/Util.svelte";
import TorrentItem from "./TorrentItem.svelte"
import IconBlock from "../../file_viewer/viewers/IconBlock.svelte";
import TextBlock from "../../file_viewer/viewers/TextBlock.svelte";
import { fs_file_url, fs_node_icon } from "../FilesystemUtil";
let dispatch = createEventDispatcher()
export let state
let status = "loading"
export const update = async () => {
dispatch("loading", true)
try {
let resp = await fetch(fs_file_url(state.root.id, state.base.path)+"?torrent_info")
if (resp.status >= 400) {
let json = await resp.json()
if (json.value === "torrent_too_large") {
status = "too_large"
return
} else {
status = "parse_failed"
return
}
}
torrent = await resp.json()
// Generate magnet link
magnet = "magnet:?xt=urn:btih:" + torrent.info_hash +
"&dn=" + encodeURIComponent(Object.keys(torrent.files.children)[0])
torrent.trackers.forEach(tracker => {
magnet += "&tr="+encodeURIComponent(tracker)
})
} catch (err) {
console.error(err)
} finally {
dispatch("loading", false)
}
status = "finished"
}
let torrent = {
trackers: [],
comment: "",
created_by: "",
created_at: "",
info_hash: "",
files: null,
}
let magnet = ""
let copy_magnet_status = "" // empty, copied, or error
const copy_magnet = () => {
if (copy_text(magnet)) {
copy_magnet_status = "copied"
} else {
copy_magnet_status = "error"
alert("Your browser does not support copying text.")
}
setTimeout(() => { copy_magnet_status = "" }, 60000)
}
</script>
<h1>{state.base.name}</h1>
<IconBlock icon_href={fs_node_icon(state.root.id, state.base)}>
{#if status === "finished"}
Created by: {torrent.created_by}<br/>
Comment: {torrent.comment}<br/>
Created at: {formatDate(new Date(torrent.created_at), true, true, true)}<br/>
Info hash: {torrent.info_hash}<br/>
<a href={magnet} class="button button_highlight">
<Magnet style=""/>
<span>Open magnet link</span>
</a>
<button
on:click={copy_magnet}
class:button_highlight={copy_magnet_status === "copied"}
class:button_red={copy_magnet_status === "error"}
>
<Magnet style=""/>
<span>
{#if copy_magnet_status === ""}
Copy magnet link
{:else if copy_magnet_status === "copied"}
Copied magnet
{:else if copy_magnet_status === "error"}
Error!
{/if}
</span>
</button>
{:else if status === "too_large"}
<p>
Torrent file is too large to parse. Please download the file and
add it to your torrent client locally.
</p>
{:else if status === "parse_failed"}
<p>
Torrent file could not be parsed. It may be corrupted.
</p>
{/if}
<button on:click={() => {dispatch("download")}} class="button">
<i class="icon">download</i>
<span>Download torrent file</span>
</button>
</IconBlock>
{#if status === "finished"}
<TextBlock>
<h2>Files in this torrent</h2>
<TorrentItem item={torrent.files} />
</TextBlock>
{/if}
<style>
h1 {
text-shadow: 1px 1px 3px var(--shadow_color);
line-break: anywhere;
}
.icon {
display: inline-block;
vertical-align: middle;
}
</style>

View File

@@ -0,0 +1,28 @@
<script>
import { formatDataVolume } from "../../util/Formatting.svelte";
export let item = {
size: 0,
children: null,
}
</script>
<ul class="list_open">
{#each Object.entries(item.children) as [name, child]}
<li class:list_closed={!child.children}>
{name} ({formatDataVolume(child.size, 3)})<br/>
{#if child.children}
<svelte:self item={child}></svelte:self>
{/if}
</li>
{/each}
</ul>
<style>
.list_open {
list-style-type: disclosure-open;
}
.list_closed {
list-style-type: disc;
}
</style>

View File

@@ -1,30 +1,45 @@
<script>
import { fs_file_url } from "../FilesystemUtil.js";
import { createEventDispatcher, onMount } from 'svelte'
import { onMount, createEventDispatcher, tick } from "svelte";
import { fs_file_url } from "../FilesystemUtil";
let dispatch = createEventDispatcher()
export let state;
export let state
// Used to detect when the file path changes
let last_path = ""
let loaded = false
let player
let playing = false
let media_session = false
let loop = false
// Detect when the song changes
$: update_session_meta(state.base.name)
const update_session_meta = name => {
export const update = async () => {
if (media_session) {
navigator.mediaSession.metadata = new MediaMetadata({
title: name,
title: state.base.name,
artist: "pixeldrain",
album: "unknown",
});
console.debug("Updating media session")
}
loop = state.base.name.includes(".loop.")
// When the component receives a new ID the video track does not
// automatically start playing the new video. So we use this little hack to
// make sure that the video is unloaded and loaded when the ID changes
if (state.base.path != last_path) {
last_path = state.base.path
loaded = false
await tick()
loaded = true
}
}
onMount(() => {
if ('mediaSession' in navigator) {
media_session = true
update_session_meta(state.base.name)
navigator.mediaSession.setActionHandler('play', () => player.play());
navigator.mediaSession.setActionHandler('pause', () => player.pause());
navigator.mediaSession.setActionHandler('stop', () => player.stop());
@@ -32,37 +47,146 @@ onMount(() => {
navigator.mediaSession.setActionHandler('nexttrack', () => dispatch("open_sibling", 1));
}
})
const toggle_play = () => playing ? player.pause() : player.play()
const seek_relative = delta => {
if (player.fastSeek) {
player.fastSeek(player.currentTime + delta)
} else {
player.currentTime = player.currentTime + delta
}
}
const mute = () => {
if (player.muted) {
// volume_seeker.disabled = false
player.muted = false
} else {
// volume_seeker.disabled = true
player.muted = true
}
}
const fullscreen = () => {
player.requestFullscreen()
}
</script>
<div class="container">
<video
bind:this={player}
class="player"
src={fs_file_url(state.root.id, state.base.path)}
autoplay="autoplay"
controls="controls"
on:ended={() => { dispatch("open_sibling", 1) }}>
<track kind="captions"/>
</video>
{#if
state.base.file_type === "video/x-matroska" ||
state.base.file_type === "video/quicktime" ||
state.base.file_type === "video/x-ms-asf"
}
<div class="compatibility_warning">
This video file type is not compatible with every web
browser. If the video fails to play you can try downloading
the video and watching it locally.
</div>
{/if}
<div class="player">
{#if loaded}
<!-- svelte-ignore a11y-media-has-caption -->
<video
bind:this={player}
controls
playsinline
autoplay
loop={loop}
class="video drop_shadow"
on:pause={() => playing = false }
on:play={() => playing = true }
on:ended={() => dispatch("next", {})}
>
<source src={fs_file_url(state.root.id, state.base.path)} type={state.base.file_type} />
</video>
{/if}
</div>
<div class="controls">
<div class="spacer"></div>
<button on:click={() => dispatch("open_sibling", -1) }>
<i class="icon">skip_previous</i>
</button>
<button on:click={() => seek_relative(-10)}>
<i class="icon">replay_10</i>
</button>
<button on:click={toggle_play} class="button_highlight">
{#if playing}
<i class="icon">pause</i>
{:else}
<i class="icon">play_arrow</i>
{/if}
</button>
<button on:click={() => seek_relative(10)}>
<i class="icon">forward_10</i>
</button>
<button on:click={() => dispatch("open_sibling", 1) }>
<i class="icon">skip_next</i>
</button>
<div style="width: 16px; height: 8px;"></div>
<button on:click={mute} class:button_red={player && player.muted}>
{#if player && player.muted}
<i class="icon">volume_off</i>
{:else}
<i class="icon">volume_up</i>
{/if}
</button>
<button on:click={fullscreen}>
<i class="icon">fullscreen</i>
</button>
<div class="spacer"></div>
</div>
</div>
<style>
.container {
position: relative;
display: block;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
}
.player {
flex: 1 1 auto;
display: flex;
justify-content: center;
text-align: center;
overflow: hidden;
}
.player {
.controls {
flex: 0 0 auto;
display: flex;
flex-direction: row;
background-color: var(--shaded_background);
padding: 0 2px 2px 2px;
align-items: center;
}
.controls > * {
flex: 0 0 auto;
}
.controls > .spacer {
flex: 1 1 auto;
}
.video {
position: relative;
display: block;
margin: auto;
max-width: 100%;
max-height: 100%;
top: 50%;
transform: translateY(-50%);
box-shadow: 1px 1px 5px var(--shadow_color);
}
@media(max-height: 500px) {
.container {
flex-direction: row;
}
.controls {
flex-direction: column;
}
}
.compatibility_warning {
background-color: var(--shaded_background);
border-bottom: 2px solid #6666FF;
padding: 4px;
}
</style>

View File

@@ -0,0 +1,93 @@
<script>
import { createEventDispatcher } from "svelte";
import { formatDataVolume, formatDate } from "../../util/Formatting.svelte"
import ZipItem from "./ZipItem.svelte";
import IconBlock from "../../file_viewer/viewers/IconBlock.svelte";
import TextBlock from "../../file_viewer/viewers/TextBlock.svelte";
import { fs_file_url, fs_node_icon } from "../FilesystemUtil";
let dispatch = createEventDispatcher()
export let state
let status = "loading"
let zip = {
size: 0,
children: null,
}
let uncomp_size = 0
let comp_ratio = 0
export const update = async () => {
dispatch("loading", true)
try {
let resp = await fetch(fs_file_url(state.root.id, state.base.path)+"?zip_info")
if (resp.status >= 400) {
status = "parse_failed"
return
}
zip = await resp.json()
uncomp_size = recursive_size(zip)
comp_ratio = (uncomp_size / state.base.file_size)
} catch (err) {
console.error(err)
} finally {
dispatch("loading", false)
}
status = "finished"
}
const recursive_size = (file) => {
let size = file.size
// If the file has children (array is iterable) we call this function on all
// the children and add the size to our size accumulator
if (file.children.forEach) {
file.children.forEach(child => {
size += recursive_size(child)
});
}
// Return the total size of this file and all its children
return size
}
</script>
<h1>{state.base.name}</h1>
<IconBlock icon_href={fs_node_icon(state.root.id, state.base)}>
Compressed size: {formatDataVolume(state.base.file_size, 3)}<br/>
Uncompressed size: {formatDataVolume(uncomp_size, 3)} (Ratio: {comp_ratio.toFixed(2)}x)<br/>
Uploaded on: {formatDate(state.base.date_created, true, true, true)}
<br/>
<button class="button_highlight" on:click={() => {dispatch("download")}}>
<i class="icon">download</i>
<span>Download</span>
</button>
</IconBlock>
{#if status === "finished"}
<TextBlock>
<h2>Files in this zip archive</h2>
<ZipItem item={zip} />
</TextBlock>
{:else if status === "parse_failed"}
<TextBlock>
<p>
Zip archive could not be parsed. It may be corrupted.
</p>
</TextBlock>
{/if}
<style>
h1 {
text-shadow: 1px 1px 3px var(--shadow_color);
line-break: anywhere;
}
</style>

View File

@@ -0,0 +1,44 @@
<script>
import { formatDataVolume } from "../../util/Formatting.svelte";
export let item = {
size: 0,
children: null,
}
</script>
<!-- First get directories and render them as details collapsibles -->
{#each Object.entries(item.children) as [name, child]}
{#if child.children}
<details>
<summary>
{name} ({formatDataVolume(child.size, 3)})
</summary>
<svelte:self item={child}></svelte:self>
</details>
{/if}
{/each}
<!-- Then get files and render them as list items -->
<ul>
{#each Object.entries(item.children) as [name, child]}
{#if !child.children}
<li>
{name} ({formatDataVolume(child.size, 3)})<br/>
</li>
{/if}
{/each}
</ul>
<style>
details {
padding-left: 12px;
border: none;
border-left: 2px solid var(--separator);
}
ul {
margin: 0;
padding-left: 30px;
border-left: 2px solid var(--separator);
}
</style>