Quellcode durchsuchen

Première version.

master
Jérôme Chauveau vor 3 Jahren
Ursprung
Commit
a453756c9f
11 geänderte Dateien mit 792 neuen und 69 gelöschten Zeilen
  1. +2
    -0
      package.json
  2. +98
    -0
      scripts/weband-id3-to-json.js
  3. +85
    -0
      scripts/webbandd.sh
  4. +21
    -11
      src/App.vue
  5. +10
    -0
      src/assets/css/_variables.css
  6. BIN
      src/assets/logo.png
  7. +0
    -58
      src/components/HelloWorld.vue
  8. +374
    -0
      src/components/Player.vue
  9. +149
    -0
      src/components/Weband.vue
  10. +36
    -0
      src/data.json.dist
  11. +17
    -0
      yarn.lock

+ 2
- 0
package.json Datei anzeigen

@@ -18,6 +18,8 @@
"babel-eslint": "^10.1.0",
"eslint": "^6.7.2",
"eslint-plugin-vue": "^6.2.2",
"fs": "^0.0.1-security",
"jsmediatags": "^3.9.3",
"vue-template-compiler": "^2.6.11"
},
"eslintConfig": {


+ 98
- 0
scripts/weband-id3-to-json.js Datei anzeigen

@@ -0,0 +1,98 @@
const jsmediatags = require('jsmediatags');
const fs = require('fs');

const INPUT = './public/audio/';
const OUTPUT = 'src/data.json';


const data = {"albums" : []};
const promises = [];

try {
if (fs.existsSync(INPUT)) {
//file exists
}
else {
console.log("\n============== Weband Error ====================\n\n");
console.log("Audio files are missing. Please run first : \n\n$ ./scripts/weband-init.sh <input_directory> \n");
console.log("\n================================================\n\n");
process.exit(1);
}
} catch(err) {
console.log(err);
process.exit(1);
}


function is_dir(path) {
try {
var stat = fs.lstatSync(path);
return stat.isDirectory();
} catch (e) {
// lstatSync throws an error if path doesn't exist
return false;
}
}




function id3ToJSON(path){
fs.readdirSync(path).forEach(file => {
let isDir = is_dir(path + file);
if(isDir)
id3ToJSON(path + file + '/');
else {
let filePath = path + file;
promises.push(new Promise((resolve, reject) => {
new jsmediatags.Reader(filePath)
.read({
onSuccess: (tag) => {
var tags = tag.tags;
if(!data.artist)
data.artist = tags.artist;
let album = getAlbum(tags.album);
if(!album){
album = {"title": tags.album, "tracks": [], "year": tags.year}
data.albums.push(album);
}
let track = {"title": tags.title, "file" : filePath.replace(INPUT,'audio/')};
album.tracks.push(track);
resolve(tag);
},
onError: (error) => {
console.log('Error');
console.log(error);
reject(error);
}
});
}));
}
});
}


function getAlbum(albumTitle){
for(let i = 0 ; i < data.albums.length; i++){
if(data.albums[i].title === albumTitle)
return data.albums[i];
}
return null;
}

id3ToJSON(INPUT);


Promise.all(promises).then((values) => {
var jsonContent = JSON.stringify(data, null, 2);
console.log(jsonContent);
fs.writeFile(OUTPUT, jsonContent, 'utf8', function (err) {
if (err) {
console.log("An error occured while writing JSON Object to File.");
return console.log(err);
}

console.log("JSON file has been saved : " + OUTPUT);
});

});

+ 85
- 0
scripts/webbandd.sh Datei anzeigen

@@ -0,0 +1,85 @@
#!/bin/bash
DIRECTORY=$(cd `dirname $0` && pwd)
IMAGES_DIRECTORY=$DIRECTORY'/../src/assets/images/'
AUDIOS_DIRECTORY=$DIRECTORY'/../public/audio/'

ICONO_HEADER='icono_1.png'
ICONO_FOOTER='icono_2.png'

display_usage(){
echo ""
echo " webbandd script"
echo ""
echo " usage : webbandd.sh <input_directory>"
echo ""
echo " <input_directory> must contains :"
echo " - two image files : icono_1.png & icono_2.png"
echo " - one folder per album with mp3 and/or wav audio files"
echo ""
}

error(){
echo ""
echo " Weband ERROR :"
echo ""
echo " "$1
echo ""
}

# 1 argument required
if [ $# -ne 1 ]
then
display_usage
exit 1
fi




# check_image(){
# if [[ -e $1'.svg' || -e $1'.png' || -e $1'.jpg' ]]
# then
# return 0
# else
# error $1' image file is missing !'
# exit -1
# fi
# }

check_image(){
if [ -e $1 ]
then
return 0
else
error $1' image file is missing !'
exit -1
fi
}

check_image $1'/'$ICONO_HEADER
echo " => "$1'/'$ICONO_HEADER" image exists - OK "
check_image $1'/'$ICONO_FOOTER
echo " => "$1'/'$ICONO_FOOTER" image exists - OK "

#copy images (cover and contact)
cp $1'/'$ICONO_HEADER $IMAGES_DIRECTORY
cp $1'/'$ICONO_FOOTER $IMAGES_DIRECTORY

if [ -d $AUDIOS_DIRECTORY ]
then
rm -rf $AUDIOS_DIRECTORY
fi

mkdir $AUDIOS_DIRECTORY



for d in `find $1/* -type d`
do
echo ' copying audio directory "'$d'"'
cp -r $d $AUDIOS_DIRECTORY
done

echo " => audio files copy - OK "

node $DIRECTORY'/weband-id3-to-json.js'

+ 21
- 11
src/App.vue Datei anzeigen

@@ -1,28 +1,38 @@
<template>
<div id="app">
<img alt="Vue logo" src="./assets/logo.png">
<HelloWorld msg="Welcome to Your Vue.js App"/>
<Weband/>
</div>
</template>

<script>
import HelloWorld from './components/HelloWorld.vue'
import Weband from './components/Weband.vue'

export default {
name: 'App',
components: {
HelloWorld
Weband
}
}
</script>

<style>
#app {
font-family: Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
@import './assets/css/_variables.css';


*{
margin :0;
padding:0;
}

body{
font-family: var(--font-family);
font-size: 2rem;
letter-spacing: 0.1rem;
}


body, html, #app {
margin :0;
padding:0;
}
</style>

+ 10
- 0
src/assets/css/_variables.css Datei anzeigen

@@ -0,0 +1,10 @@
@font-face {
font-family: "work-sans";
src: url('../fonts/work-sans/WorkSans-Regular.otf');
}
:root{
--font-family : 'work-sans';
--pretty-margin: 0.5rem;
--main-color: black;
--secondary-color: black;
}

BIN
src/assets/logo.png Datei anzeigen

Vorher Nachher
Breite: 200  |  Höhe: 200  |  Größe: 6.7 KiB

+ 0
- 58
src/components/HelloWorld.vue Datei anzeigen

@@ -1,58 +0,0 @@
<template>
<div class="hello">
<h1>{{ msg }}</h1>
<p>
For a guide and recipes on how to configure / customize this project,<br>
check out the
<a href="https://cli.vuejs.org" target="_blank" rel="noopener">vue-cli documentation</a>.
</p>
<h3>Installed CLI Plugins</h3>
<ul>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-babel" target="_blank" rel="noopener">babel</a></li>
<li><a href="https://github.com/vuejs/vue-cli/tree/dev/packages/%40vue/cli-plugin-eslint" target="_blank" rel="noopener">eslint</a></li>
</ul>
<h3>Essential Links</h3>
<ul>
<li><a href="https://vuejs.org" target="_blank" rel="noopener">Core Docs</a></li>
<li><a href="https://forum.vuejs.org" target="_blank" rel="noopener">Forum</a></li>
<li><a href="https://chat.vuejs.org" target="_blank" rel="noopener">Community Chat</a></li>
<li><a href="https://twitter.com/vuejs" target="_blank" rel="noopener">Twitter</a></li>
<li><a href="https://news.vuejs.org" target="_blank" rel="noopener">News</a></li>
</ul>
<h3>Ecosystem</h3>
<ul>
<li><a href="https://router.vuejs.org" target="_blank" rel="noopener">vue-router</a></li>
<li><a href="https://vuex.vuejs.org" target="_blank" rel="noopener">vuex</a></li>
<li><a href="https://github.com/vuejs/vue-devtools#vue-devtools" target="_blank" rel="noopener">vue-devtools</a></li>
<li><a href="https://vue-loader.vuejs.org" target="_blank" rel="noopener">vue-loader</a></li>
<li><a href="https://github.com/vuejs/awesome-vue" target="_blank" rel="noopener">awesome-vue</a></li>
</ul>
</div>
</template>

<script>
export default {
name: 'HelloWorld',
props: {
msg: String
}
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
h3 {
margin: 40px 0 0;
}
ul {
list-style-type: none;
padding: 0;
}
li {
display: inline-block;
margin: 0 10px;
}
a {
color: #42b983;
}
</style>

+ 374
- 0
src/components/Player.vue Datei anzeigen

@@ -0,0 +1,374 @@
<template>
<div v-if="!collapsed" class="player">
<div class="progress-wrap">
<div class="time-info">
{{audio ? formatDuration(audio.currentTime) : "00:00"}}
</div>
<progress :value="audioPosition" :max="audio ? Math.floor(audio.duration): 0"></progress>
<div class="time-info">
{{audio ? formatDuration(audio.duration) : "00:00"}}
</div>
</div>
<h3>{{album.title}}</h3>
<ul>
<li
v-for="(track, index) in album.tracks"
v-on:click="trackClicked('track-'+index)"
v-bind:key="index+'-'+track.title"
v-bind:class="{ active: audio && audio.getAttribute('id') === 'track-'+index }">
<span class="track-number">{{index + 1}}</span>
<span class="track-title">{{track.title.trim()}}</span>
<audio v-on:timeupdate="updateAudioPosition()" v-bind:src="track.file" v-bind:id="'track-'+ index"></audio>
</li>
</ul>
<div class="button-wrap">
<img v-if="playing" v-on:click="pause()" src="../assets/images/pause.png" id="play-button">
<img v-else v-on:click="play()" src="../assets/images/play.png" id="play-button">
</div>
<div class="player-switch opened" v-on:click="toggle()"></div>
</div>
<div v-else class="player-switch" v-on:click="toggle()">
</div>
</template>

<script>


export default {
name: 'Player',
props: {
album: Object,
},

data: function () {
return {
switcher : null,
collapsed: true,
playing: false,
audioPosition: 0,
audio : null,
startTimeInfo: "00:00",
endTimeInfo: "00:00",
playerImage: '../assets/images/play.png'
}
},
updated() {
//this.bindAudioTags();
},

mounted(){
this.switcher = document.getElementsByClassName('player-switch')[0];
document.body.append(this.switcher);

// this.currentAudio = null;
// this.playing = false;
// this.playerContainer = document.createElement("div");
// this.container = document.querySelector('#player')
// this.playerContainer.classList.add('dd-player');
// this.switcher = document.createElement("div");
// this.switcher.classList.add('dd-player-switch');
// this.playerContainer.classList.add('collapsed');
// document.body.appendChild(this.switcher);
//
// this.switcher.addEventListener('click', () => {
// this.playerContainer.classList.toggle('collapsed');
// this.switcher.classList.toggle('opened');
// });
//
//
// this.addProgressBar();
// this.bindAudioTags();
// this.addPlayButton();
//
// let wrap = document.createElement("div");
// wrap.setAttribute('id', 'wrap-playlist')
// this.container.parentElement.appendChild(this.playerContainer);
// this.playerContainer.appendChild(wrap);
// wrap.appendChild(this.container);

},

methods: {

toggle : function(){
// this.playerContainer.classList.toggle('collapsed');
this.collapsed = !this.collapsed;
this.switcher.classList.toggle('opened');
},

play: function(){
if(!this.audio)
this.audio = document.querySelectorAll('audio')[0];
this.playing = true;
this.audio.play();
// if(this.playing){
// this.audio.play();
// // this.currentAudio.parentElement.classList.add('playing');
// // self.progressBar.setAttribute('max', Math.floor(self.currentAudio.duration));
// // self.endTimeInfo.innerHTML = self.formatDuration(self.currentAudio.duration);
// // this.playButton.setAttribute('src','./images/pause.png');
// }
// else{
// this.audio.pause();
// // self.currentAudio.parentElement.classList.remove('playing');
// // this.playButton.setAttribute('src','./images/play.png');
// }
},

pause: function(){
this.playing = false;
this.audio.pause();
},

trackClicked: function(audioId){
//let track = document.getElementById(audioId);
if(this.playing){
this.pause();
this.audio = document.getElementById(audioId);
}
else{
this.audio = document.getElementById(audioId);
}
this.playing = true;
this.audio.play();
},

updateAudioPosition: function(){
this.audioPosition = Math.floor(this.audio.currentTime);
},

// addProgressBar: function(){
// this.progressBar = document.createElement("progress");
// this.progressBar.setAttribute('value', 0);
// this.startTimeInfo = document.createElement('div');
// this.endTimeInfo = document.createElement('div');
// let progressWrap = document.createElement("div");
// this.startTimeInfo.innerHTML = "00:00";
// this.endTimeInfo.innerHTML = "00:00";
// progressWrap.appendChild(this.startTimeInfo);
// progressWrap.appendChild(this.progressBar);
// progressWrap.appendChild(this.endTimeInfo);
// this.startTimeInfo.classList.add('time-info');
// this.endTimeInfo.classList.add('time-info');
// progressWrap.classList.add('progress-wrap');
// this.playerContainer.append(progressWrap);
// let self = this;
// this.progressBar.addEventListener('click', (e) => {
// if(self.currentAudio){
// self.currentAudio.currentTime = Math.floor(((e.layerX - self.progressBar.offsetLeft) * self.currentAudio.duration) / self.progressBar.offsetWidth);
// self.progressBar.value = Math.floor(self.currentAudio.currentTime );
// }
// });
//
// },


// addPlayButton: function(){
// this.playButton = document.createElement("img");
// this.playButton.setAttribute('src','./images/play.png');
// this.playButton.setAttribute('id','play-button');
// let wrap = document.createElement("div");
// wrap.append(this.playButton);
// this.container.append(wrap);
// let self = this;
// this.playButton.addEventListener('click', () => {
// self.playing = !self.playing;
// if(self.playing){
// self.currentAudio.play();
// self.currentAudio.parentElement.classList.add('playing');
// self.progressBar.setAttribute('max', Math.floor(self.currentAudio.duration));
// self.endTimeInfo.innerHTML = self.formatDuration(self.currentAudio.duration);
// this.playButton.setAttribute('src','./images/pause.png');
// }
// else{
// self.currentAudio.pause();
// self.currentAudio.parentElement.classList.remove('playing');
// this.playButton.setAttribute('src','./images/play.png');
// }
// })
// },

// bindAudioTags: function(){
// let liTags = this.container.querySelectorAll('li')
// let self = this;
// let i = 0;
// liTags.forEach(function(itemElement){
// let audioTag = itemElement.querySelector('audio')
// if(audioTag){
// audioTag.addEventListener('timeupdate', () => {
// self.audioTimeUpdate(audioTag);
// });
// audioTag.addEventListener('ended', () => {
// self.audioEnded(i);
// })
//
// //itemElement.appendChild(audioElement);
// audioTag.setAttribute('id','track-'+i);
// if(i === 0)
// self.currentAudio = audioTag;
// itemElement.addEventListener('click', () => {
// let items = self.container.getElementsByTagName('li');
// for(let i = 0; i < items.length; i++){
// items[i].classList.remove('playing');
// }
// if(self.currentAudio)
// self.currentAudio.pause();
// audioTag.play();
// self.playButton.setAttribute('src','./images/pause.png');
// self.playing = true;
// itemElement.classList.add('playing');
// self.currentAudio = audioTag;
// self.progressBar.setAttribute('max', Math.floor(self.currentAudio.duration));
// self.endTimeInfo.innerHTML = self.formatDuration(self.currentAudio.duration);
// self.progressBar.setAttribute('value', 0);
// });
//
// i++;
// }
// });
// },

// audioTimeUpdate: function(audioElt){
// this.progressBar.value = Math.floor(audioElt.currentTime);
// this.startTimeInfo.innerHTML = this.formatDuration(audioElt.currentTime);
// },
//
// audioEnded: function(n){
// let audios = document.getElementsByTagName('audio');
// let next = n == audios.length - 1 ? 0 : n + 1;
// this.currentAudio.pause();
// this.currentAudio.currentTime = 0;
// this.currentAudio = document.getElementsByTagName('audio')[next];
// this.currentAudio.parentElement.click();
// },

formatDuration: function(time){
let s = parseInt(time% 60);
let m = parseInt((time / 60) % 60);
return (m < 10 ? ('0'+m) : m) + ':' + (s < 10 ? ('0'+s) : s);
}
}
}
</script>

<style>

*{
--one-dpi: 72px;
--dd-blue: rgb(0,42,255);
}


.player{
width: 25vw;
height: 100vh;
position:fixed;
top: 0px;
background-color: white;
right:0px;
color: black;
padding-top: var(--one-dpi);
padding-bottom: var(--one-dpi);
display: flex;
flex-direction: column;
/* justify-content: space-between; */
}

.collapsed{
display: none;
}

.player-switch{
position :fixed;
bottom: 0;
right: 0;
width: var(--one-dpi);
height:var(--one-dpi);
background-color: white;
cursor: pointer;
z-index:10;
}

.player-switch.opened{
background-color: var(--dd-blue);
}

li.active{
font-weight: bold;
}

.player h3 {
font-size: 1rem;
margin: calc(var(--one-dpi) / 2);
}

.player ul {
margin-left : calc(var(--one-dpi) / 2);
color: var(--dd-blue);
font-size: 1.5rem;
list-style-type : none;
}
/*
.dd-player ul li img{
width:24px;
height:24px;
margin-right: 12px;
}

.playing{
font-weight: bold;
color : black;
}
*/
.progress-wrap{
display:flex;
width: 100%;
justify-content: space-around;

}
.progress-wrap .time-info{
font-size:0.66rem;
display: flex;
justify-content: center;
}

progress{
display: inline;
height: 10px;
width:66%;
}

/*
#play-button{
max-width: calc(var(--one-dpi) / 2);
max-height: calc(var(--one-dpi) / 2);
margin: calc(var(--one-dpi) / 2);
}
*/
progress::-moz-progress-bar{
background-color: black;
}
progress{
background-color: #rgba(200,200,200,0.5);
}
.button-wrap{
margin-top: 100px;
}
/*
.dd-player ul li, #play-button{
cursor : pointer;
}

#wrap-playlist{
flex: 1;
}
*/
span.track-number{
width: 25px;
display: inline-block;
}
span.track-title:before{
content: " - ";
}

</style>

+ 149
- 0
src/components/Weband.vue Datei anzeigen

@@ -0,0 +1,149 @@
<template>
<div class="weband">
<section class="cover">
</section>
<section class="albums">
<h2>{{this.artist}} | Albums</h2>
<ul>
<li v-for="album in albums" v-bind:key="album.title" v-on:click="albumSelected(album)">
{{album.title}} | {{album.year}}
</li>
</ul>
</section>

<section class="icono-2">

</section>

<section class="contact">
<span>contact<span class="point">@</span>{{this.artist.toLowerCase()}}<span class="point">.</span>space</span>
<footer class="flex-center">
dede<span class='point'>.</span>space<span class='pipe'>|</span>{{new Date().getFullYear()}}
</footer>
</section>

<Player v-bind:album="currentAlbum"/>

</div>
</template>

<script>

import Player from './Player.vue'
import * as data from '../data.json';

export default {
name: 'Weband',
props: {
msg: String
},
components: {
Player
},
data: function () {
return {
artist: data.artist,
albums: data.albums,
currentAlbum: data.albums[0]
}
},

methods: {
albumSelected: function(album){
this.currentAlbum = album;
}
}
}
</script>

<style scoped>


@import '../assets/css/_variables.css';


.point {
margin: 0px var(--pretty-margin);
}

.pipe {
margin: 0px calc(3 * var(--pretty-margin));
}

section{
height: 100vh;
}

section h2 {
font-size: 2rem;
}

section:nth-of-type(odd){
background-color: white;
color: var(--secondary-color);
}

section:nth-of-type(even){
background-color: var(--secondary-color);
color: white;
}

section.cover{
background : url(../assets/images/icono_1.png) no-repeat center center;
background-color: var(--main-color);

}

section.albums{
background-color: black;
color:white;
display: flex;
flex-direction: column;
justify-content: center;
line-height: 200%;
}

section.albums > * {
padding-left: 10vw;
}

section.albums h2 {
padding-left: 20vw;
font-weight: normal;
}

section.albums ul {
list-style-type: none;
}

section.icono-2{
background : url(../assets/images/icono_2.png) no-repeat center center;
background-color: var(--secondary-color);
}

section.contact{
display : flex;
flex-direction: column;
justify-content: center;
align-items: center;
position :relative;

/* background : url(../assets/images/contact.png) no-repeat center center; */
background-color: var(--main-color);
/*color:white; */

}


footer{
font-size: 0.92rem;
position: absolute;
width: 100%;
bottom: 0;
text-align: center;
}

.hidden{
display: none;
}
</style>

+ 36
- 0
src/data.json.dist Datei anzeigen

@@ -0,0 +1,36 @@
{
"albums" : [
{
"title" : "Album 1",
"date" : "2019",
"tracks" : [
{
"file" : "audio/album_1/01.mp3",
"title" : "Track 1"
},
{
"file" : "audio/album_1/02.mp3",
"title" : "Track 2"
},
{
"file" : "audio/album_1/03.mp3",
"title" : "Track 3"
}
]
},
{
"title" : "Album 2",
"date" : "2020",
"tracks" : [
{
"file" : "audio/album_2/04.mp3",
"title" : "Track X"
},
{
"file" : "audio/album_2/05.mp3",
"title" : "Track Y"
}
]
}
]
}

+ 17
- 0
yarn.lock Datei anzeigen

@@ -3729,6 +3729,11 @@ fs.realpath@^1.0.0:
resolved "https://registry.yarnpkg.com/fs.realpath/-/fs.realpath-1.0.0.tgz#1504ad2523158caa40db4a2787cb01411994ea4f"
integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=

fs@^0.0.1-security:
version "0.0.1-security"
resolved "https://registry.yarnpkg.com/fs/-/fs-0.0.1-security.tgz#8a7bd37186b6dddf3813f23858b57ecaaf5e41d4"
integrity sha1-invTcYa23d84E/I4WLV+yq9eQdQ=

fsevents@^1.2.7:
version "1.2.13"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-1.2.13.tgz#f325cb0455592428bcf11b383370ef70e3bfcc38"
@@ -4699,6 +4704,13 @@ jsesc@~0.5.0:
resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-0.5.0.tgz#e7dee66e35d6fc16f710fe91d5cf69f70f08911d"
integrity sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=

jsmediatags@^3.9.3:
version "3.9.3"
resolved "https://registry.yarnpkg.com/jsmediatags/-/jsmediatags-3.9.3.tgz#309632d221d701bd385df65c9c6840cb399e11ff"
integrity sha512-h53yFnPYF1Y5jwr2ebcVzIIsvRpSalm0jhNiJDUztoPPHGpuHxi9YHUzdDgiw+ykiinXHd1s6HSIbudHw79zQw==
dependencies:
xhr2 "^0.1.4"

json-parse-better-errors@^1.0.1, json-parse-better-errors@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/json-parse-better-errors/-/json-parse-better-errors-1.0.2.tgz#bb867cfb3450e69107c131d1c514bab3dc8bcaa9"
@@ -8190,6 +8202,11 @@ ws@^6.0.0, ws@^6.2.1:
dependencies:
async-limiter "~1.0.0"

xhr2@^0.1.4:
version "0.1.4"
resolved "https://registry.yarnpkg.com/xhr2/-/xhr2-0.1.4.tgz#7f87658847716db5026323812f818cadab387a5f"
integrity sha1-f4dliEdxbbUCYyOBL4GMras4el8=

xtend@^4.0.0, xtend@~4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54"


Laden…
Abbrechen
Speichern