Przeglądaj źródła

UI stuff + audio player implementation

v2
choj 4 lat temu
rodzic
commit
8e6f937efb
36 zmienionych plików z 348 dodań i 195 usunięć
  1. +20
    -2
      README.md
  2. +67
    -27
      assets/css/labelize.css
  3. +2
    -0
      assets/css/player.css
  4. BIN
      assets/fonts/noto-sans/NotoSans-Bold.ttf
  5. BIN
      assets/fonts/noto-sans/NotoSans-BoldItalic.ttf
  6. BIN
      assets/fonts/noto-sans/NotoSans-Italic.ttf
  7. BIN
      assets/fonts/noto-sans/NotoSans-Regular.ttf
  8. +2
    -1
      assets/fonts/work-sans/SIL Open Font License.txt
  9. BIN
      assets/fonts/work-sans/WorkSans-Black.otf
  10. BIN
      assets/fonts/work-sans/WorkSans-BlackItalic.otf
  11. BIN
      assets/fonts/work-sans/WorkSans-Bold.otf
  12. BIN
      assets/fonts/work-sans/WorkSans-BoldItalic.otf
  13. BIN
      assets/fonts/work-sans/WorkSans-ExtraBold.otf
  14. BIN
      assets/fonts/work-sans/WorkSans-ExtraBoldItalic.otf
  15. BIN
      assets/fonts/work-sans/WorkSans-ExtraLight.otf
  16. BIN
      assets/fonts/work-sans/WorkSans-ExtraLightItalic.otf
  17. BIN
      assets/fonts/work-sans/WorkSans-Hairline.otf
  18. BIN
      assets/fonts/work-sans/WorkSans-Italic.otf
  19. BIN
      assets/fonts/work-sans/WorkSans-Light.otf
  20. BIN
      assets/fonts/work-sans/WorkSans-LightItalic.otf
  21. BIN
      assets/fonts/work-sans/WorkSans-Medium.otf
  22. BIN
      assets/fonts/work-sans/WorkSans-MediumItalic.otf
  23. BIN
      assets/fonts/work-sans/WorkSans-Regular.otf
  24. BIN
      assets/fonts/work-sans/WorkSans-SemiBold.otf
  25. BIN
      assets/fonts/work-sans/WorkSans-SemiBoldItalic.otf
  26. BIN
      assets/fonts/work-sans/WorkSans-Thin.otf
  27. BIN
      assets/fonts/work-sans/WorkSans-ThinItalic.otf
  28. BIN
      assets/fonts/work-sans/worksans-italic-vf.ttf
  29. BIN
      assets/fonts/work-sans/worksans-roman-vf.ttf
  30. BIN
      assets/images/pause.png
  31. BIN
      assets/images/play.png
  32. +55
    -50
      assets/js/labelize.js
  33. +173
    -0
      assets/js/player.js
  34. +0
    -95
      labelize.py
  35. +11
    -2
      labelize.yaml
  36. +18
    -18
      template.html

+ 20
- 2
README.md Wyświetl plik

@@ -1,6 +1,6 @@
# Labelize # Labelize


Music album web page generator : 1 folder with music files + 1 cover image = 1 single page app with audio player
Music band web page generator : 1 folder with music files + images + 1 optional configuration file = 1 single page app with audio player


# Installation # Installation


@@ -11,10 +11,28 @@ $ pip install -r requirements.txt


``` ```


#Configuration

In a labelize.yaml file :

````
band: band name
images:
cover: my_cover_image.png
contact: my_contact_image.png
albums_section :
title: Album section tile
albums:
- year : 2019
title : my album title
- year : 2018
title : my album title

````
# Usage # Usage


``` ```
$ python labelize.py inputDirectory outputDirectory
$ python labelize.py [inputDirectory] [outputDirectory]
``` ```


# Credentials # Credentials


+ 67
- 27
assets/css/labelize.css Wyświetl plik

@@ -1,60 +1,100 @@
@font-face {
font-family: "work-sans";
src: url('../fonts/work-sans/WorkSans-Regular.otf');
}

:root{
--footer-font-family : 'work-sans';
--pretty-margin: 0.5rem;
--main-color: red;
--secondary-color: rgb(0,42,255);
}

*{ *{
margin: 0; margin: 0;
padding: 0; padding: 0;
} }


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

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

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

section{ section{
height: 100vh; height: 100vh;
} }


section h2 {
font-size: 2rem;
}

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


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


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

} }


.one-dpi{
position : fixed;
bottom: 0;
right: 0;
width : 72px;
height: 72px;
section.albums{
background-color: black; background-color: black;
z-index: 10;
cursor: pointer;
color:white;
display: flex;
flex-direction: column;
justify-content: center;
line-height: 200%;
} }


.one-dpi.clicked {
background : white;
section.albums > * {
padding-left: 10vw;
} }


.player {
position : fixed;
right : 0;
top : 0;
height : 100vh;
width : 25vw;
color : black;
z-index: 5;
background-color: black;
color: white;
padding: 25px;
section.albums h2 {
padding-left: 20vw;
font-weight: normal;
} }
.player ul {

section.albums ul {
list-style-type: none; list-style-type: none;
} }
.player li {
height: 10vh;

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

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

}


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


.hidden{ .hidden{


assets/css/dede-player.css → assets/css/player.css Wyświetl plik

@@ -30,6 +30,7 @@
height:var(--one-dpi); height:var(--one-dpi);
background-color: white; background-color: white;
cursor: pointer; cursor: pointer;
z-index:10;
} }


.dd-player-switch.opened{ .dd-player-switch.opened{
@@ -47,6 +48,7 @@
margin-left : calc(var(--one-dpi) / 2); margin-left : calc(var(--one-dpi) / 2);
color: var(--dd-blue); color: var(--dd-blue);
font-size: 1.5rem; font-size: 1.5rem;
list-style-type : none;
} }


.dd-player ul li img{ .dd-player ul li img{

BIN
assets/fonts/noto-sans/NotoSans-Bold.ttf Wyświetl plik


BIN
assets/fonts/noto-sans/NotoSans-BoldItalic.ttf Wyświetl plik


BIN
assets/fonts/noto-sans/NotoSans-Italic.ttf Wyświetl plik


BIN
assets/fonts/noto-sans/NotoSans-Regular.ttf Wyświetl plik


assets/fonts/noto-sans/SIL Open Font License.txt → assets/fonts/work-sans/SIL Open Font License.txt Wyświetl plik

@@ -1,4 +1,5 @@
Copyright 2012 Google Inc. All Rights Reserved.
Copyright (c) 2014-2015 Wei Huang (wweeiihhuuaanngg@gmail.com)



This Font Software is licensed under the SIL Open Font License, Version 1.1. This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL

BIN
assets/fonts/work-sans/WorkSans-Black.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-BlackItalic.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-Bold.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-BoldItalic.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-ExtraBold.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-ExtraBoldItalic.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-ExtraLight.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-ExtraLightItalic.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-Hairline.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-Italic.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-Light.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-LightItalic.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-Medium.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-MediumItalic.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-Regular.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-SemiBold.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-SemiBoldItalic.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-Thin.otf Wyświetl plik


BIN
assets/fonts/work-sans/WorkSans-ThinItalic.otf Wyświetl plik


BIN
assets/fonts/work-sans/worksans-italic-vf.ttf Wyświetl plik


BIN
assets/fonts/work-sans/worksans-roman-vf.ttf Wyświetl plik


BIN
assets/images/pause.png Wyświetl plik

Przed Po
Szerokość: 640  |  Wysokość: 640  |  Rozmiar: 17 KiB Szerokość: 48  |  Wysokość: 48  |  Rozmiar: 225 B

BIN
assets/images/play.png Wyświetl plik

Przed Po
Szerokość: 640  |  Wysokość: 640  |  Rozmiar: 19 KiB Szerokość: 48  |  Wysokość: 48  |  Rozmiar: 299 B

+ 55
- 50
assets/js/labelize.js Wyświetl plik

@@ -1,7 +1,12 @@
var current = undefined
const progressMargin = 50;
var progress = undefined;
import DedePlayer from './player.js';

window.addEventListener('DOMContentLoaded', (event) => { window.addEventListener('DOMContentLoaded', (event) => {
const player = new DedePlayer(document.getElementById('player'));
})
//var current = undefined
//const progressMargin = 50;
//var progress = undefined;
//window.addEventListener('DOMContentLoaded', (event) => {
// progress = document.getElementById('player-progress'); // progress = document.getElementById('player-progress');
// document.getElementById('player-progress').addEventListener('click', (e) => { // document.getElementById('player-progress').addEventListener('click', (e) => {
// if(current){ // if(current){
@@ -16,50 +21,50 @@ window.addEventListener('DOMContentLoaded', (event) => {
// toggler.addEventListener('click', (e) => { // toggler.addEventListener('click', (e) => {
// document.getElementsByTagName('ul')[0].classList.toggle('invisible'); // document.getElementsByTagName('ul')[0].classList.toggle('invisible');
// }) // })
});
function togglePlay(src) {
let media = src.getElementsByTagName('audio')[0];
let icon = src.getElementsByTagName('img')[0];
if (current){//stops current media and reset its play icon
pause();
}
if(current != src){
//sets current media icon and plays media
icon.setAttribute('src','assets/pause.png');
media.play();
progress.max = Math.floor(media.duration);
current = src;
document.getElementById('time-info').style.visibility = 'visible';
}
else{
current = undefined;
}
}
function pause(){
current.getElementsByTagName('audio')[0].pause();
current.getElementsByTagName('img')[0].setAttribute('src','assets/play.png');
document.getElementById('time-info').style.visibility = 'hidden';
}
function updateProgress(media){
progress.value = Math.floor(media.currentTime);
document.getElementById('time-info').innerHTML =
prettyDuration(media.currentTime) + ' / ' + prettyDuration(media.duration);
}
function prettyDuration(duration) {
let sec = Math.floor( duration );
let min = Math.floor( sec / 60 );
min = min >= 10 ? min : '0' + min;
sec = Math.floor( sec % 60 );
sec = sec >= 10 ? sec : '0' + sec;
return min + ':' + sec;
}
function togglePlayer(playerId, dpiId){
document.getElementById(playerId).classList.toggle('hidden');
document.getElementById(dpiId).classList.toggle('clicked');
}
//});
//
//
//function togglePlay(src) {
// let media = src.getElementsByTagName('audio')[0];
// let icon = src.getElementsByTagName('img')[0];
// if (current){//stops current media and reset its play icon
// pause();
// }
// if(current != src){
// //sets current media icon and plays media
// icon.setAttribute('src','assets/pause.png');
// media.play();
// progress.max = Math.floor(media.duration);
// current = src;
// document.getElementById('time-info').style.visibility = 'visible';
// }
// else{
// current = undefined;
// }
//}
//
//function pause(){
// current.getElementsByTagName('audio')[0].pause();
// current.getElementsByTagName('img')[0].setAttribute('src','assets/play.png');
// document.getElementById('time-info').style.visibility = 'hidden';
//}
//
//function updateProgress(media){
// progress.value = Math.floor(media.currentTime);
// document.getElementById('time-info').innerHTML =
// prettyDuration(media.currentTime) + ' / ' + prettyDuration(media.duration);
//}
//
//function prettyDuration(duration) {
// let sec = Math.floor( duration );
// let min = Math.floor( sec / 60 );
// min = min >= 10 ? min : '0' + min;
// sec = Math.floor( sec % 60 );
// sec = sec >= 10 ? sec : '0' + sec;
// return min + ':' + sec;
//}
//
//function togglePlayer(playerId, dpiId){
// document.getElementById(playerId).classList.toggle('hidden');
// document.getElementById(dpiId).classList.toggle('clicked');
//}

+ 173
- 0
assets/js/player.js Wyświetl plik

@@ -0,0 +1,173 @@
export default class DedePlayer{

constructor(container){
this.currentAudio = null;
this.playing = false;
this.playerContainer = document.createElement("div");
this.container = container;
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.container);
document.body.appendChild(this.switcher);

this.switcher.addEventListener('click', event => {
this.playerContainer.classList.toggle('collapsed');
this.switcher.classList.toggle('opened');
});

let title = document.createElement("h3");
//title.innerHTML = playList.title;
title.appendChild(document.createTextNode(this.container.dataset.title));

this.addProgressBar();
// this.container.appendChild(title);
this.bindAudioTags();
this.addPlayButton();
//console.log(Math.floor(this.currentAudio.duration));

// let playerLinks = document.querySelectorAll('.dd-player-link');
// let self = this;
// playerLinks.forEach(function(pl){
// pl.addEventListener('click', (e) => {
// self.container.classList.toggle('collapsed');
// self.switcher.classList.toggle('opened');
// })
// });

let wrap = document.createElement("div");
wrap.setAttribute('id', 'wrap-playlist')
this.container.parentElement.appendChild(this.playerContainer);
this.playerContainer.appendChild(title);
this.playerContainer.appendChild(wrap);
wrap.appendChild(this.container);

};

addProgressBar(){
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(){
this.playButton = document.createElement("img");
this.playButton.setAttribute('src','assets/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', event => {
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','assets/images/pause.png');
}
else{
self.currentAudio.pause();
self.currentAudio.parentElement.classList.remove('playing');
this.playButton.setAttribute('src','assets/images/play.png');
}
})
}

bindAudioTags(){
//let listElement = document.createElement("ul");
let liTags = this.container.querySelectorAll('li')
let self = this;
let i = 0;
liTags.forEach(function(itemElement){
// //let playBtn = document.createElement("img");
// //playBtn.setAttribute('src','images/icons/play.png');
// let itemElement = document.createElement("li");
// //itemElement.appendChild(playBtn);
// itemElement.appendChild(document.createTextNode(track.title));
// listElement.appendChild(itemElement);
// let audioElement = document.createElement('audio');
// audioElement.setAttribute('src',track.src);
let audioTag = itemElement.querySelector('audio')

audioTag.addEventListener('timeupdate', (event) => {
self.audioTimeUpdate(audioTag);
});
audioTag.addEventListener('ended', (event) => {
self.audioEnded(i);
})

//itemElement.appendChild(audioElement);
audioTag.setAttribute('id','track-'+i);
if(i === 0)
self.currentAudio = audioTag;
itemElement.addEventListener('click', event => {
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','assets/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(audioElt){
this.progressBar.value = Math.floor(audioElt.currentTime);
this.startTimeInfo.innerHTML = this.formatDuration(audioElt.currentTime);
}

audioEnded(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(time){
let s = parseInt(time% 60);
let m = parseInt((time / 60) % 60);
return (m < 10 ? ('0'+m) : m) + ':' + (s < 10 ? ('0'+s) : s);
}


}

+ 0
- 95
labelize.py Wyświetl plik

@@ -1,95 +0,0 @@
import eyed3
import os
import sys
import shutil
from html_writer import Html


# TracksDir class definition
class TracksDir:
def __init__(self, track_files):
self.trackFiles = track_files

# HTML Output
def to_html(self):
head = Html()
head.self_close_tag('meta', attributes=dict(charset='utf-8'))
head.self_close_tag('link', attributes=dict(href='assets/labelize.css', rel='stylesheet'))
body = Html()
track_number = 0
with body.tag('div', classes=['track-list']):
# body.tag_with_content('Track List', name='h2')
with body.tag('div', attributes=dict(id='player-toggler')):
body.self_close_tag('img', attributes=dict(src='assets/labelize.png'))
with body.tag('ul') as list:
for t in self.trackFiles:
track_number = track_number + 1
track_id = 'track-' + str(track_number)
audio_file = eyed3.load("build/" + t)
with body.tag('li') as li:
print(audio_file.tag.title)
li += str(audio_file.tag.title or 'untitled') + ' - '
li += str(audio_file.tag.album or 'untitled') + ' - '
li += audio_file.tag.artist
with body.tag('button', attributes=dict(onclick="togglePlay(this)")):
body.self_close_tag('img', attributes=dict(src='assets/play.png'))
with body.tag('audio', attributes=dict(src=t, id=track_id,
ontimeupdate="updateProgress(this)")) as audio:
audio += "Your browser does not support the audio element"

# with body.tag('canvas',attributes=dict(id='player-progress')) as canvas:
# canvas += "player's progress bar"
with body.tag('progress', attributes=dict(id='player-progress', value='0')) as canvas:
canvas += "player's progress bar"
with body.tag('div', attributes=dict(id='time-info')) as timeInfo:
timeInfo += '00:00'
with body.tag('script', attributes=dict(src='assets/labelize.js')) as script:
script += "" # script tag is not added without that trick

return Html.html_template(head, body).to_raw_html(indent_size=2)


# script usage function
def usage():
print('USAGE : labelize.py [inputDirectory] [outputDirectory]')


# script beginning
arguments = len(sys.argv) - 1
if arguments != 2:
usage()
sys.exit(2)

input_directory = sys.argv[1]
output_directory = sys.argv[2]


def copy_directory(src, dest):
try:
shutil.copytree(src, dest)
except shutil.Error as e:
print('Directory not copied. Error: %s' % e)
except OSError as e:
print('Directory not copied. Error: %s' % e)


# removes existing build directory if exists
if os.path.isdir(output_directory):
shutil.rmtree(output_directory)

# copies source files in the build directory
copy_directory(input_directory, 'build')

# copies assets in the build directory
copy_directory('assets', output_directory + '/assets')

for root, dirs, files in os.walk(input_directory):
trackFiles = []
for f in files:
if f.lower().endswith(('.mp3', '.wav', '.ogg')):
trackFiles.append(f)

td = TracksDir(trackFiles)
f = open(output_directory + "/index.html", "w+")
f.write(td.to_html())
f.close()

+ 11
- 2
labelize.yaml Wyświetl plik

@@ -1,2 +1,11 @@
title: Clou
cover: image.png
band: Clou
images:
cover: image.png
contact: image2.png
albums_section :
title: CLOU albums | with LABELAR
albums:
- year : 2019
title : p
- year : 2018
title : x

+ 18
- 18
template.html Wyświetl plik

@@ -8,7 +8,7 @@
<title>${title}</title> <title>${title}</title>
<meta name="description" content="dedemo"> <meta name="description" content="dedemo">
<meta name="author" content="dede.space"> <meta name="author" content="dede.space">
<link rel="stylesheet" href="assets/css/dede-player.css">
<link rel="stylesheet" href="assets/css/player.css">
<link rel="stylesheet" href="assets/css/labelize.css"> <link rel="stylesheet" href="assets/css/labelize.css">
</head> </head>


@@ -17,34 +17,34 @@


<!-- <img src="images/CLOU_Icono-1.png"/> --> <!-- <img src="images/CLOU_Icono-1.png"/> -->
</section> </section>
<section class="dates">
<h2><span class="scaps">${title}</span> live</h2>
</section>
<!-- <section class="dates">-->
<!-- <h2><span class="scaps">${title}</span> live</h2>-->
<!-- </section>-->
<section class="albums"> <section class="albums">

<h2>${albums_section['title']}</h2>
<ul>
% for a in albums_section['albums']:
<li>${a['year']} : ${a['title']}</li>
% endfor
</ul>
</section> </section>




<section class="contact"> <section class="contact">
<span>contact<span class="point">@</span>clou<span class="point">.</span>space</span> <span>contact<span class="point">@</span>clou<span class="point">.</span>space</span>
<footer class="flex-center"> <footer class="flex-center">
dede <span class='point'>.</span>space<span class='pipe'>|</span>2019
dede<span class='point'>.</span>space<span class='pipe'>|</span>2019
</footer> </footer>
</section> </section>


<div class="one-dpi" id="one-dpi" onclick="togglePlayer('player','one-dpi');"></div>
<ul id="player" data-title="Album title">
% for t in tracks:
<li>${loop.index}<audio src="audio/01.mp3" id="track-${loop.index}"></audio></li>
% endfor
</ul>


<div class="player hidden" id="player">
<h2>${title} - Audio</h2>
<ul>
% for a in tracks:
<li>Item ${loop.index}: ${a}</li>
% endfor
</ul>
</div>


<script src="./assets/js/labelize.js"></script>
<script type="module" src="./assets/js/labelize.js"></script>

</body> </body>
</html> </html>




Ładowanie…
Anuluj
Zapisz