simple video test (2)
now with DASH (instead of HLS)
This commit is contained in:
parent
fc4558be2c
commit
c8b232d494
9 changed files with 342 additions and 11 deletions
11
README.md
11
README.md
|
|
@ -1,3 +1,14 @@
|
|||
# OwnMedia
|
||||
|
||||
Built with Zig 0.15.1 & Vite 7.1.12
|
||||
|
||||
## Testing different file streaming methods
|
||||
### HLS
|
||||
```sh
|
||||
ffmpeg -i test.mp4 -codec: copy -hls_time 10 -hls_list_size 0 -f hls test.m3u8
|
||||
```
|
||||
|
||||
### DASH
|
||||
```sh
|
||||
ffmpeg -i test.mp4 -map 0:v:0 -map 0:a:0 -codec: copy -use_timeline 1 -use_template 1 -f dash test.mpd
|
||||
```
|
||||
|
|
|
|||
197
package-lock.json
generated
197
package-lock.json
generated
|
|
@ -6,6 +6,7 @@
|
|||
"": {
|
||||
"dependencies": {
|
||||
"@inertiajs/vue3": "^2.2.11",
|
||||
"dashjs": "^5.0.3",
|
||||
"hls.js": "^1.6.14",
|
||||
"tailwindcss": "^4.1.16"
|
||||
},
|
||||
|
|
@ -1245,6 +1246,12 @@
|
|||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@svta/common-media-library": {
|
||||
"version": "0.12.4",
|
||||
"resolved": "https://registry.npmjs.org/@svta/common-media-library/-/common-media-library-0.12.4.tgz",
|
||||
"integrity": "sha512-9EuOoaNmz7JrfGwjsrD9SxF9otU5TNMnbLu1yU4BeLK0W5cDxVXXR58Z89q9u2AnHjIctscjMTYdlqQ1gojTuw==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.1.16",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz",
|
||||
|
|
@ -1682,6 +1689,45 @@
|
|||
"proxy-from-env": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/bcp-47": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/bcp-47/-/bcp-47-2.1.0.tgz",
|
||||
"integrity": "sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-alphabetical": "^2.0.0",
|
||||
"is-alphanumerical": "^2.0.0",
|
||||
"is-decimal": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/bcp-47-match": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/bcp-47-match/-/bcp-47-match-2.0.3.tgz",
|
||||
"integrity": "sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/bcp-47-normalize": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/bcp-47-normalize/-/bcp-47-normalize-2.3.0.tgz",
|
||||
"integrity": "sha512-8I/wfzqQvttUFz7HVJgIZ7+dj3vUaIyIxYXaTRP1YWoSDfzt6TUmxaKZeuXR62qBmYr+nvuWINFRl6pZ5DlN4Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bcp-47": "^2.0.0",
|
||||
"bcp-47-match": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/braces": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
|
||||
|
|
@ -1753,6 +1799,12 @@
|
|||
"url": "https://paulmillr.com/funding/"
|
||||
}
|
||||
},
|
||||
"node_modules/codem-isoboxer": {
|
||||
"version": "0.3.10",
|
||||
"resolved": "https://registry.npmjs.org/codem-isoboxer/-/codem-isoboxer-0.3.10.tgz",
|
||||
"integrity": "sha512-eNk3TRV+xQMJ1PEj0FQGY8KD4m0GPxT487XJ+Iftm7mVa9WpPFDMWqPt+46buiP5j5Wzqe5oMIhqBcAeKfygSA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/colorjs.io": {
|
||||
"version": "0.5.2",
|
||||
"resolved": "https://registry.npmjs.org/colorjs.io/-/colorjs.io-0.5.2.tgz",
|
||||
|
|
@ -1781,6 +1833,24 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/dashjs": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dashjs/-/dashjs-5.0.3.tgz",
|
||||
"integrity": "sha512-TXndNnCUjFjF2nYBxDVba+hWRpVkadkQ8flLp7kHkem+5+wZTfRShJCnVkPUosmjS0YPE9fVNLbYPJxHBeQZvA==",
|
||||
"license": "BSD-3-Clause",
|
||||
"dependencies": {
|
||||
"@svta/common-media-library": "^0.12.4",
|
||||
"bcp-47-match": "^2.0.3",
|
||||
"bcp-47-normalize": "^2.3.0",
|
||||
"codem-isoboxer": "0.3.10",
|
||||
"fast-deep-equal": "3.1.3",
|
||||
"html-entities": "^2.5.2",
|
||||
"imsc": "^1.1.5",
|
||||
"localforage": "^1.10.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"ua-parser-js": "^1.0.37"
|
||||
}
|
||||
},
|
||||
"node_modules/delayed-stream": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
|
|
@ -1935,6 +2005,12 @@
|
|||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/fast-deep-equal": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
|
|
@ -2141,6 +2217,28 @@
|
|||
"integrity": "sha512-CSpT2aXsv71HST8C5ETeVo+6YybqCpHBiYrCRQSn3U5QUZuLTSsvtq/bj+zuvjLVADeKxoebzo16OkH8m1+65Q==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/html-entities": {
|
||||
"version": "2.6.0",
|
||||
"resolved": "https://registry.npmjs.org/html-entities/-/html-entities-2.6.0.tgz",
|
||||
"integrity": "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/mdevils"
|
||||
},
|
||||
{
|
||||
"type": "patreon",
|
||||
"url": "https://patreon.com/mdevils"
|
||||
}
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immediate": {
|
||||
"version": "3.0.6",
|
||||
"resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
|
||||
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/immutable": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
||||
|
|
@ -2150,6 +2248,49 @@
|
|||
"optional": true,
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/imsc": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/imsc/-/imsc-1.1.5.tgz",
|
||||
"integrity": "sha512-V8je+CGkcvGhgl2C1GlhqFFiUOIEdwXbXLiu1Fcubvvbo+g9inauqT3l0pNYXGoLPBj3jxtZz9t+wCopMkwadQ==",
|
||||
"license": "BSD-2-Clause",
|
||||
"dependencies": {
|
||||
"sax": "1.2.1"
|
||||
}
|
||||
},
|
||||
"node_modules/is-alphabetical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
|
||||
"integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-alphanumerical": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
|
||||
"integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"is-alphabetical": "^2.0.0",
|
||||
"is-decimal": "^2.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-decimal": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
|
||||
"integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/wooorm"
|
||||
}
|
||||
},
|
||||
"node_modules/is-extglob": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
|
||||
|
|
@ -2199,6 +2340,15 @@
|
|||
"jiti": "lib/jiti-cli.mjs"
|
||||
}
|
||||
},
|
||||
"node_modules/lie": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz",
|
||||
"integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"immediate": "~3.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.30.2",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz",
|
||||
|
|
@ -2460,6 +2610,15 @@
|
|||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/localforage": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz",
|
||||
"integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"lie": "3.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash-es": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||
|
|
@ -2575,6 +2734,12 @@
|
|||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/path-browserify": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
|
||||
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
|
|
@ -3105,6 +3270,12 @@
|
|||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz",
|
||||
"integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/side-channel": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
|
||||
|
|
@ -3291,6 +3462,32 @@
|
|||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/ua-parser-js": {
|
||||
"version": "1.0.41",
|
||||
"resolved": "https://registry.npmjs.org/ua-parser-js/-/ua-parser-js-1.0.41.tgz",
|
||||
"integrity": "sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/ua-parser-js"
|
||||
},
|
||||
{
|
||||
"type": "paypal",
|
||||
"url": "https://paypal.me/faisalman"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/faisalman"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ua-parser-js": "script/cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/varint": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/varint/-/varint-6.0.0.tgz",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@
|
|||
},
|
||||
"dependencies": {
|
||||
"@inertiajs/vue3": "^2.2.11",
|
||||
"dashjs": "^5.0.3",
|
||||
"hls.js": "^1.6.14",
|
||||
"tailwindcss": "^4.1.16"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,13 +25,18 @@ pub fn SendInertiaResponse(endpoint: anytype, context: *@This(), r: zap.Request,
|
|||
// const tags = try model_tag.getTopLevelTags(context.db_connection, arena);
|
||||
// const cats = try model_cat.getTopLevelcats(context.db_connection, arena);
|
||||
|
||||
var uri = endpoint.path;
|
||||
if (r.query) |q| {
|
||||
uri = try std.fmt.allocPrint(arena, "{s}?{s}", .{ endpoint.path, q });
|
||||
}
|
||||
|
||||
const inertia_json = try std.fmt.allocPrint(
|
||||
arena,
|
||||
"{f}",
|
||||
.{std.json.fmt(.{
|
||||
.component = component,
|
||||
.props = props,
|
||||
.url = endpoint.path,
|
||||
.url = uri,
|
||||
.version = "",
|
||||
}, .{
|
||||
.escape_unicode = true,
|
||||
|
|
|
|||
|
|
@ -16,12 +16,8 @@ import { Link } from "@inertiajs/vue3";
|
|||
</div>
|
||||
</header>
|
||||
<div class="p-4 h-full">
|
||||
<div class="p-4 bg-rose-950 rounded h-full">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
<footer class="bg-slate-800 px-4">
|
||||
footer
|
||||
</footer>
|
||||
<footer class="bg-slate-800 px-4">footer</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
|
|
|||
81
src/modules/media/BasePlayerDASH.vue
Normal file
81
src/modules/media/BasePlayerDASH.vue
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
<template>
|
||||
<video
|
||||
@pause="pause"
|
||||
@ended="pause"
|
||||
@keyup="changeSpeed"
|
||||
ref="video"
|
||||
:poster="previewImageLink"
|
||||
:controls="isControls"
|
||||
:title="title"
|
||||
>
|
||||
<source :src="link"/>
|
||||
</video>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, onUpdated, ref } from "vue";
|
||||
import * as dashjs from 'dashjs'
|
||||
|
||||
const props = defineProps({
|
||||
previewImageLink: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
link: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
progress: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
isMuted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isControls: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["pause", "test"]);
|
||||
const video = ref(null);
|
||||
|
||||
onMounted(() => {
|
||||
prepareVideoPlayer();
|
||||
});
|
||||
|
||||
onUpdated(() => {
|
||||
prepareVideoPlayer();
|
||||
});
|
||||
|
||||
function prepareVideoPlayer() {
|
||||
let player = dashjs.MediaPlayer().create();
|
||||
player.initialize();
|
||||
player.attachSource(props.link);
|
||||
if (video.value) {
|
||||
player.attachView(video.value);
|
||||
video.value.muted = props.isMuted;
|
||||
video.value.currentTime = props.progress;
|
||||
}
|
||||
}
|
||||
|
||||
function pause() {
|
||||
const currentTime = video?.value?.currentTime || 0;
|
||||
|
||||
emit("pause", currentTime);
|
||||
}
|
||||
|
||||
function changeSpeed(e) {
|
||||
if (e.key === "w" && video.value) {
|
||||
video.value.playbackRate = video.value.playbackRate + 0.25;
|
||||
} else if (e.key === "s" && video.value) {
|
||||
video.value.playbackRate = video.value.playbackRate - 0.25;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
<script setup lang="ts">
|
||||
import Layout from "@/layout/Layout.vue";
|
||||
import { Head, Link } from "@inertiajs/vue3";
|
||||
import BasePlayer from "./BasePlayer.vue";
|
||||
import BasePlayer from "./BasePlayerDASH.vue";
|
||||
|
||||
const props = defineProps<{ thread_id; counter }>();
|
||||
|
||||
|
|
@ -9,11 +9,11 @@ function pause(currentTime) {
|
|||
console.log("PAUSE", currentTime);
|
||||
}
|
||||
|
||||
const link = '/test.m3u8';
|
||||
const previewImageLink = '/test.jpg';
|
||||
const link = "/test.mpd";
|
||||
const previewImageLink = "/test.jpg";
|
||||
const isMuted = false;
|
||||
const isControls = true;
|
||||
const progress_S = 15;
|
||||
const progress_S = 0;
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
|||
40
src/modules/media/video.zig
Normal file
40
src/modules/media/video.zig
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const builtin = @import("builtin");
|
||||
const optimize_mode = builtin.mode;
|
||||
|
||||
const zap = @import("zap");
|
||||
const sqlite = @import("sqlite");
|
||||
|
||||
// The global Application Context
|
||||
const Context = @import("Context");
|
||||
|
||||
// A very simple endpoint handling only GET requests
|
||||
const SimpleEndpoint = struct {
|
||||
|
||||
// zap.App.Endpoint Interface part
|
||||
path: []const u8,
|
||||
error_strategy: zap.Endpoint.ErrorStrategy = .log_to_response,
|
||||
|
||||
// data specific for this endpoint
|
||||
component: []const u8,
|
||||
|
||||
pub fn init(path: []const u8, data: []const u8) SimpleEndpoint {
|
||||
return .{
|
||||
.path = path,
|
||||
.component = data,
|
||||
};
|
||||
}
|
||||
|
||||
// handle GET requests
|
||||
pub fn get(e: *SimpleEndpoint, arena: Allocator, context: *Context, r: zap.Request) !void {
|
||||
const thread_id = std.Thread.getCurrentId();
|
||||
|
||||
r.setStatus(.ok);
|
||||
try Context.SendInertiaResponse(e, context, r, arena, e.component, .{
|
||||
.thread_id = thread_id,
|
||||
.counter = context.counter,
|
||||
});
|
||||
context.*.counter += 1;
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue