浏览代码

UI stuff + audio player implementation

v2
choj 5 年前
父节点
当前提交
8e6f937efb
共有 36 个文件被更改,包括 348 次插入195 次删除
  1. +20
    -2
      README.md
  2. +67
    -27
      assets/css/labelize.css
  3. +2
    -0
      assets/css/player.css
  4. 二进制
      assets/fonts/noto-sans/NotoSans-Bold.ttf
  5. 二进制
      assets/fonts/noto-sans/NotoSans-BoldItalic.ttf
  6. 二进制
      assets/fonts/noto-sans/NotoSans-Italic.ttf
  7. 二进制
      assets/fonts/noto-sans/NotoSans-Regular.ttf
  8. +2
    -1
      assets/fonts/work-sans/SIL Open Font License.txt
  9. 二进制
      assets/fonts/work-sans/WorkSans-Black.otf
  10. 二进制
      assets/fonts/work-sans/WorkSans-BlackItalic.otf
  11. 二进制
      assets/fonts/work-sans/WorkSans-Bold.otf
  12. 二进制
      assets/fonts/work-sans/WorkSans-BoldItalic.otf
  13. 二进制
      assets/fonts/work-sans/WorkSans-ExtraBold.otf
  14. 二进制
      assets/fonts/work-sans/WorkSans-ExtraBoldItalic.otf
  15. 二进制
      assets/fonts/work-sans/WorkSans-ExtraLight.otf
  16. 二进制
      assets/fonts/work-sans/WorkSans-ExtraLightItalic.otf
  17. 二进制
      assets/fonts/work-sans/WorkSans-Hairline.otf
  18. 二进制
      assets/fonts/work-sans/WorkSans-Italic.otf
  19. 二进制
      assets/fonts/work-sans/WorkSans-Light.otf
  20. 二进制
      assets/fonts/work-sans/WorkSans-LightItalic.otf
  21. 二进制
      assets/fonts/work-sans/WorkSans-Medium.otf
  22. 二进制
      assets/fonts/work-sans/WorkSans-MediumItalic.otf
  23. 二进制
      assets/fonts/work-sans/WorkSans-Regular.otf
  24. 二进制
      assets/fonts/work-sans/WorkSans-SemiBold.otf
  25. 二进制
      assets/fonts/work-sans/WorkSans-SemiBoldItalic.otf
  26. 二进制
      assets/fonts/work-sans/WorkSans-Thin.otf
  27. 二进制
      assets/fonts/work-sans/WorkSans-ThinItalic.otf
  28. 二进制
      assets/fonts/work-sans/worksans-italic-vf.ttf
  29. 二进制
      assets/fonts/work-sans/worksans-roman-vf.ttf
  30. 二进制
      assets/images/pause.png
  31. 二进制
      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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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{

二进制
assets/fonts/noto-sans/NotoSans-Bold.ttf 查看文件


二进制
assets/fonts/noto-sans/NotoSans-BoldItalic.ttf 查看文件


二进制
assets/fonts/noto-sans/NotoSans-Italic.ttf 查看文件


二进制
assets/fonts/noto-sans/NotoSans-Regular.ttf 查看文件


assets/fonts/noto-sans/SIL Open Font License.txt → assets/fonts/work-sans/SIL Open Font License.txt 查看文件

@@ -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

二进制
assets/fonts/work-sans/WorkSans-Black.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-BlackItalic.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-Bold.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-BoldItalic.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-ExtraBold.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-ExtraBoldItalic.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-ExtraLight.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-ExtraLightItalic.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-Hairline.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-Italic.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-Light.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-LightItalic.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-Medium.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-MediumItalic.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-Regular.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-SemiBold.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-SemiBoldItalic.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-Thin.otf 查看文件


二进制
assets/fonts/work-sans/WorkSans-ThinItalic.otf 查看文件


二进制
assets/fonts/work-sans/worksans-italic-vf.ttf 查看文件


二进制
assets/fonts/work-sans/worksans-roman-vf.ttf 查看文件


二进制
assets/images/pause.png 查看文件

之前 之后
宽度: 640  |  高度: 640  |  大小: 17 KiB 宽度: 48  |  高度: 48  |  大小: 225 B

二进制
assets/images/play.png 查看文件

之前 之后
宽度: 640  |  高度: 640  |  大小: 19 KiB 宽度: 48  |  高度: 48  |  大小: 299 B

+ 55
- 50
assets/js/labelize.js 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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 查看文件

@@ -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>




正在加载...
取消
保存