mirror of https://github.com/FreeTubeApp/FreeTube
Add Search for channels, fix playlist view, fix quality select
This commit is contained in:
parent
c436e37d5c
commit
8e82bbcefb
|
@ -151,6 +151,7 @@
|
|||
<div id='mainHeaderView'></div>
|
||||
<div id='channelVideosView'></div>
|
||||
<div id='channelPlaylistsView'></div>
|
||||
<div id='channelSearchView'></div>
|
||||
<div id='searchView'></div>
|
||||
<div id='subscriptionView'></div>
|
||||
<div id='popularView'></div>
|
||||
|
|
|
@ -31,7 +31,8 @@
|
|||
function goToChannel(channelId) {
|
||||
|
||||
channelView.channelId = channelId;
|
||||
channelView.page = 2;
|
||||
channelView.channelSearchValue = '';
|
||||
channelVideosView.page = 2;
|
||||
|
||||
headerView.title = 'Latest Uploads';
|
||||
hideViews();
|
||||
|
@ -64,6 +65,8 @@ function goToChannel(channelId) {
|
|||
|
||||
channelVideosView.videoList = [];
|
||||
|
||||
channelView.featuredChannels = data.relatedChannels;
|
||||
|
||||
const views = [
|
||||
aboutView,
|
||||
headerView,
|
||||
|
@ -116,14 +119,16 @@ function goToChannel(channelId) {
|
|||
function channelNextPage() {
|
||||
showToast('Fetching results, please wait…');
|
||||
|
||||
invidiousAPI('channels/videos', channelView.channelId, { 'page': channelView.page }, (data) => {
|
||||
let sortValue = document.getElementById('channelVideosSortValue').value;
|
||||
|
||||
invidiousAPI('channels/videos', channelView.channelId, {'sort_by': sortValue, 'page': channelVideosView.page }, (data) => {
|
||||
ft.log(data);
|
||||
data.forEach((video) => {
|
||||
displayVideo(video, 'channel');
|
||||
});
|
||||
});
|
||||
|
||||
channelView.page = channelView.page + 1;
|
||||
channelVideosView.page = channelVideosView.page + 1;
|
||||
}
|
||||
|
||||
function channelPlaylistNextPage() {
|
||||
|
@ -134,15 +139,52 @@ function channelPlaylistNextPage() {
|
|||
|
||||
showToast('Fetching results, please wait…');
|
||||
|
||||
invidiousAPI('channels/playlists', channelView.channelId, { 'continuation': channelPlaylistsView.continuationString }, (data) => {
|
||||
let sortValue = document.getElementById('channelVideosSortValue').value;
|
||||
|
||||
invidiousAPI('channels/playlists', channelView.channelId, {'sort_by': sortValue, 'continuation': channelPlaylistsView.continuationString }, (data) => {
|
||||
console.log(data);
|
||||
channelPlaylistsView.continuationString = data.continuation;
|
||||
data.playlists.forEach((playlist) => {
|
||||
displayPlaylist(playlist, 'channelPlaylist');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
channelView.page = channelView.page + 1;
|
||||
function channelSearchKeypress(e) {
|
||||
if (e.keyCode === 13) {
|
||||
channelView.search();
|
||||
}
|
||||
}
|
||||
|
||||
function searchChannel() {
|
||||
if (channelView.channelSearchValue === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
showToast('Fetching results, please wait…');
|
||||
|
||||
invidiousAPI('channels/search', channelView.channelId, {
|
||||
q: channelView.channelSearchValue,
|
||||
page: channelSearchView.page,
|
||||
}, function (data) {
|
||||
ft.log(data);
|
||||
|
||||
data.forEach((video) => {
|
||||
switch (video.type) {
|
||||
case 'video':
|
||||
displayVideo(video, 'channelSearch');
|
||||
break;
|
||||
case 'playlist':
|
||||
if (video.videoCount > 0) {
|
||||
displayPlaylist(video, 'channelSearch');
|
||||
}
|
||||
break;
|
||||
default:
|
||||
}
|
||||
});
|
||||
|
||||
channelSearchView.page = channelSearchView.page + 1;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -630,7 +630,7 @@ function openMiniPlayer() {
|
|||
};
|
||||
miniPlayer.webContents.send('ping', playerData);
|
||||
|
||||
var tmpSize = [0,0];
|
||||
let tmpSize = [0,0];
|
||||
|
||||
miniPlayer.on('resize', (e)=>{
|
||||
var size = miniPlayer.getSize()
|
||||
|
@ -812,7 +812,16 @@ function clickMiniPlayer(videoId) {
|
|||
miniPlayer.show();
|
||||
miniPlayer.webContents.send('ping', videoData);
|
||||
showToast('Video has been opened in a new window.');
|
||||
// TODO: Add video to history once fully loaded.
|
||||
|
||||
let tmpSize = [0,0];
|
||||
|
||||
miniPlayer.on('resize', (e)=>{
|
||||
var size = miniPlayer.getSize()
|
||||
if( Math.abs(size[0]-tmpSize[0]) > 2 || Math.abs(size[1]-tmpSize[1]) > 2){
|
||||
miniPlayer.setSize(size[0], parseInt(size[0] * 9 / 16))
|
||||
}
|
||||
tmpSize = size;
|
||||
});
|
||||
});
|
||||
|
||||
if (rememberHistory === true) {
|
||||
|
@ -892,6 +901,11 @@ function clickMiniPlayer(videoId) {
|
|||
|
||||
videoData.videoId = videoId;
|
||||
videoData.videoDash = invidiousInstance + '/api/manifest/dash/' + videoId + '.mpd?unique_res=1';
|
||||
|
||||
if (settingsView.proxyVideos) {
|
||||
videoData.videoDash = videoData.videoDash + '&local=true';
|
||||
}
|
||||
|
||||
videoData.autoplay = autoplay;
|
||||
videoData.enableSubtitles = enableSubtitles;
|
||||
videoData.quality = defaultQuality;
|
||||
|
@ -1084,19 +1098,6 @@ function checkDashSettings() {
|
|||
return;
|
||||
}
|
||||
|
||||
historyDb.findOne({
|
||||
videoId: playerView.videoId
|
||||
}, function(err, doc) {
|
||||
if (doc !== null) {
|
||||
if (typeof(playerView.currentTime) !== 'undefined') {
|
||||
instance.currentTime = playerView.currentTime;
|
||||
playerView.currentTime = undefined;
|
||||
} else if (doc.watchProgress < instance.duration - 5 && playerView.validLive === false) {
|
||||
instance.currentTime = doc.watchProgress;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let selectedOption = false;
|
||||
qualityOptions.forEach((option, index) => {
|
||||
if (option.value === defaultQuality || option.value === defaultQuality + 'p' || option.value === defaultQuality + 'p60') {
|
||||
|
@ -1109,9 +1110,26 @@ function checkDashSettings() {
|
|||
// Assume user selected a higher quality as their default. Select the highest option available.
|
||||
ft.log('Quality not available.');
|
||||
ft.log(qualityOptions.reverse()[0]);
|
||||
console.log(selectedOption);
|
||||
|
||||
qualityOptions.reverse()[0].click();
|
||||
$('.mejs__qualities-selector-input').get().reverse()[0].click();
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
historyDb.findOne({
|
||||
videoId: playerView.videoId
|
||||
}, function(err, doc) {
|
||||
if (doc !== null) {
|
||||
console.log('History');
|
||||
if (typeof(playerView.currentTime) !== 'undefined') {
|
||||
instance.currentTime = playerView.currentTime;
|
||||
playerView.currentTime = undefined;
|
||||
} else if (doc.watchProgress < instance.duration - 5 && playerView.validLive === false) {
|
||||
instance.currentTime = doc.watchProgress;
|
||||
}
|
||||
}
|
||||
});
|
||||
}, 200);
|
||||
};
|
||||
|
||||
initializeSettings();
|
||||
|
|
|
@ -381,11 +381,19 @@ let playlistView = new Vue({
|
|||
toggleSave: (videoId) => {
|
||||
addSavedVideo(videoId);
|
||||
},
|
||||
openYouTube: (videoId) => {
|
||||
const url = 'https://youtube.com/watch?v=' + videoId;
|
||||
shell.openExternal(url);
|
||||
},
|
||||
copyYouTube: (videoId) => {
|
||||
const url = 'https://youtube.com/watch?v=' + videoId;
|
||||
clipboard.writeText(url);
|
||||
showToast('URL has been copied to the clipboard');
|
||||
},
|
||||
openInvidious: (videoId) => {
|
||||
const url = invidiousInstance + '/watch?v=' + videoId;
|
||||
shell.openExternal(url);
|
||||
},
|
||||
copyInvidious: (videoId) => {
|
||||
const url = invidiousInstance + '/watch?v=' + videoId;
|
||||
clipboard.writeText(url);
|
||||
|
@ -538,27 +546,32 @@ let channelView = new Vue({
|
|||
id: '',
|
||||
name: '',
|
||||
icon: '',
|
||||
baner: '',
|
||||
banner: '',
|
||||
subCount: '',
|
||||
subButtonText: '',
|
||||
description: '',
|
||||
distractionFreeMode: false
|
||||
channelSearchValue: '',
|
||||
distractionFreeMode: false,
|
||||
featuredChannels: [],
|
||||
},
|
||||
methods: {
|
||||
videoTab: () => {
|
||||
channelVideosView.seen = true;
|
||||
channelView.aboutTabSeen = false;
|
||||
channelPlaylistsView.seen = false;
|
||||
channelSearchView.seen = false;
|
||||
},
|
||||
playlistTab: () => {
|
||||
channelPlaylistsView.seen = true;
|
||||
channelVideosView.seen = false;
|
||||
channelView.aboutTabSeen = false;
|
||||
channelSearchView.seen = false;
|
||||
},
|
||||
aboutTab: () => {
|
||||
channelView.aboutTabSeen = true;
|
||||
channelVideosView.seen = false;
|
||||
channelPlaylistsView.seen = false;
|
||||
channelSearchView.seen = false;
|
||||
},
|
||||
subscription: (channelId) => {
|
||||
let channelData = {
|
||||
|
@ -568,6 +581,49 @@ let channelView = new Vue({
|
|||
};
|
||||
toggleSubscription(channelData);
|
||||
},
|
||||
sort: () => {
|
||||
if (channelVideosView.seen) {
|
||||
channelVideosView.page = 1;
|
||||
channelVideosView.videoList = [];
|
||||
channelNextPage();
|
||||
}
|
||||
else {
|
||||
// Playlist View is active
|
||||
channelPlaylistsView.continuationString = '';
|
||||
channelPlaylistsView.videoList = [];
|
||||
channelPlaylistNextPage();
|
||||
}
|
||||
},
|
||||
search: () => {
|
||||
channelSearchView.page = 1;
|
||||
channelSearchView.videoList = [];
|
||||
channelView.aboutTabSeen = false;
|
||||
channelVideosView.seen = false;
|
||||
channelPlaylistsView.seen = false;
|
||||
channelSearchView.seen = true;
|
||||
searchChannel();
|
||||
},
|
||||
goToChannel: (channelId) => {
|
||||
goToChannel(channelId);
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
sortOptions: () => {
|
||||
if (channelVideosView.seen) {
|
||||
return [
|
||||
{value: 'newest', label: 'Newest'},
|
||||
{value: 'oldest', label: 'Oldest'},
|
||||
{value: 'popular', label: 'Most Popular'},
|
||||
];
|
||||
}
|
||||
else {
|
||||
return [
|
||||
{value: 'newest', label: 'Newest'},
|
||||
{value: 'oldest', label: 'Oldest'},
|
||||
{value: 'last', label: 'Last Video Added'},
|
||||
];
|
||||
}
|
||||
},
|
||||
},
|
||||
template: channelTemplate
|
||||
});
|
||||
|
@ -576,7 +632,6 @@ let channelVideosView = new Vue({
|
|||
el: '#channelVideosView',
|
||||
data: {
|
||||
seen: false,
|
||||
channelId: '',
|
||||
isSearch: true,
|
||||
page: 2,
|
||||
videoList: []
|
||||
|
@ -619,7 +674,6 @@ let channelPlaylistsView = new Vue({
|
|||
el: '#channelPlaylistsView',
|
||||
data: {
|
||||
seen: false,
|
||||
channelId: '',
|
||||
isSearch: true,
|
||||
page: 2,
|
||||
continuationString: '',
|
||||
|
@ -642,6 +696,53 @@ let channelPlaylistsView = new Vue({
|
|||
template: videoListTemplate
|
||||
});
|
||||
|
||||
let channelSearchView = new Vue({
|
||||
el: '#channelSearchView',
|
||||
data: {
|
||||
seen: false,
|
||||
channelId: '',
|
||||
isSearch: true,
|
||||
page: 2,
|
||||
videoList: []
|
||||
},
|
||||
methods: {
|
||||
play: (videoId) => {
|
||||
loadingView.seen = true;
|
||||
playVideo(videoId);
|
||||
},
|
||||
channel: (channelId) => {
|
||||
goToChannel(channelId);
|
||||
},
|
||||
playlist: (playlistId) => {
|
||||
showPlaylist(playlistId);
|
||||
},
|
||||
toggleSave: (videoId) => {
|
||||
addSavedVideo(videoId);
|
||||
},
|
||||
nextPage: () => {
|
||||
showToast('Fetching results. Please wait…');
|
||||
searchChannel();
|
||||
},
|
||||
copyYouTube: (videoId) => {
|
||||
const url = 'https://youtube.com/watch?v=' + videoId;
|
||||
clipboard.writeText(url);
|
||||
showToast('URL has been copied to the clipboard');
|
||||
},
|
||||
copyInvidious: (videoId) => {
|
||||
const url = invidiousInstance + '/watch?v=' + videoId;
|
||||
clipboard.writeText(url);
|
||||
showToast('URL has been copied to the clipboard');
|
||||
},
|
||||
history: (videoId) => {
|
||||
removeFromHistory(videoId);
|
||||
},
|
||||
miniPlayer: (videoId) => {
|
||||
clickMiniPlayer(videoId);
|
||||
}
|
||||
},
|
||||
template: videoListTemplate
|
||||
});
|
||||
|
||||
let playerView = new Vue({
|
||||
el: '#playerView',
|
||||
data: {
|
||||
|
@ -1562,6 +1663,7 @@ function hideViews() {
|
|||
channelView.seen = false;
|
||||
channelVideosView.seen = false;
|
||||
channelPlaylistsView.seen = false;
|
||||
channelSearchView.seen = false;
|
||||
profileSelectView.seen = false;
|
||||
subscriptionManagerView.seen = false;
|
||||
editProfileView.seen = false;
|
||||
|
|
|
@ -235,6 +235,10 @@ function displayVideo(videoData, listType = '') {
|
|||
channelVideosView.videoList = channelVideosView.videoList.concat(video);
|
||||
video.removeFromSave = false;
|
||||
break;
|
||||
case 'channelSearch':
|
||||
channelSearchView.videoList = channelSearchView.videoList.concat(video);
|
||||
video.removeFromSave = false;
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -286,6 +290,9 @@ function displayPlaylist(playlist, listType) {
|
|||
case 'channelPlaylist':
|
||||
channelPlaylistsView.videoList = channelPlaylistsView.videoList.concat(playListData);
|
||||
break;
|
||||
case 'channelSearch':
|
||||
channelSearchView.videoList = channelSearchView.videoList.concat(playListData);
|
||||
break;
|
||||
default:
|
||||
searchView.videoList = searchView.videoList.concat(playListData);
|
||||
}
|
||||
|
|
|
@ -83,6 +83,33 @@
|
|||
transition: background 0.2s ease-in;
|
||||
}
|
||||
|
||||
.channelVideosSortSelect {
|
||||
float: right;
|
||||
top: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.channelSearch {
|
||||
float: left;
|
||||
margin-left: 15px;
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.channelSearch input {
|
||||
width: 100%;
|
||||
margin: auto;
|
||||
}
|
||||
|
||||
.channelViewDescription {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.featuredChannel {
|
||||
display: inline-block;
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.featuredChannel img {
|
||||
border-radius: 200px 200px 200px 200px;
|
||||
-webkit-border-radius: 200px 200px 200px 200px;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
width: 5px;
|
||||
background: #262626;
|
||||
}
|
||||
|
||||
|
@ -219,6 +219,19 @@ input[type=text] {
|
|||
color: white;
|
||||
}
|
||||
|
||||
.playlistVideoOptions ul {
|
||||
background-color: #262626;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.playlistVideoOptions li:hover {
|
||||
background-color: #262626;
|
||||
}
|
||||
|
||||
.playlistVideoOptions a {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.profile:hover {
|
||||
background-color: #616161;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
::-webkit-scrollbar {
|
||||
height: 12px;
|
||||
width: 12px;
|
||||
width: 5px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
|
@ -188,6 +188,18 @@ body {
|
|||
color: black;
|
||||
}
|
||||
|
||||
.playlistVideoOptions ul {
|
||||
background-color: #f5f5f5;
|
||||
}
|
||||
|
||||
.playlistVideoOptions li:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.playlistVideoOptions a {
|
||||
color: black;
|
||||
}
|
||||
|
||||
.profile:hover {
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
|
|
@ -17,7 +17,7 @@
|
|||
|
||||
.playlistVideoView {
|
||||
float: right;
|
||||
width: 60%;
|
||||
width: 58%;
|
||||
}
|
||||
|
||||
.playlistVideoThumbnail {
|
||||
|
@ -80,6 +80,50 @@
|
|||
cursor: pointer;
|
||||
}
|
||||
|
||||
.playlistVideoOptions {
|
||||
float: right;
|
||||
width: 37px;
|
||||
}
|
||||
|
||||
.playlistVideoOptions i {
|
||||
padding: 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.playlistVideoOptions ul {
|
||||
width: 90px;
|
||||
font-size: 12px;
|
||||
position: relative;
|
||||
right: 110px;
|
||||
bottom: 10px;
|
||||
list-style-type: none;
|
||||
display: none;
|
||||
-webkit-box-shadow: 4px 4px 33px -7px rgba(0, 0, 0, 0.75);
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.playlistVideoOptions li {
|
||||
width: 110px;
|
||||
position: relative;
|
||||
right: 40px;
|
||||
padding: 10px;
|
||||
-webkit-transition: background 0.2s ease-out;
|
||||
-moz-transition: background 0.2s ease-out;
|
||||
-o-transition: background 0.2s ease-out;
|
||||
transition: background 0.2s ease-out;
|
||||
}
|
||||
|
||||
.playlistVideoOptions li:hover {
|
||||
-moz-transition: background 0.2s ease-in;
|
||||
-o-transition: background 0.2s ease-in;
|
||||
transition: background 0.2s ease-in;
|
||||
}
|
||||
|
||||
.playlistVideoOptions a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
#miniPL {
|
||||
width: 100%;
|
||||
}
|
||||
|
|
|
@ -12,6 +12,24 @@
|
|||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<div class="select channelVideosSortSelect" v-if='!aboutTabSeen'>
|
||||
<select id='channelVideosSortValue' class="select-text" v-on:change='sort' >
|
||||
<option v-for='option in sortOptions' :value='option.value'>{{option.label}}</option>
|
||||
</select>
|
||||
<span class="select-highlight"></span>
|
||||
<span class="select-bar"></span>
|
||||
<label class="select-label">Sort By</label>
|
||||
</div>
|
||||
<div class="input-text-settings channelSearch">
|
||||
<label for="channelSearchInput">Search Channel</label>
|
||||
<input type="text" id="channelSearchInput" onkeyup="channelSearchKeypress(event)" name="set-name" v-model="channelSearchValue" />
|
||||
<span class='searchButton' v-on:click='search'><i class="fas fa-arrow-right" style='position: relative; float: right; right: 5px; bottom: 30px; cursor: pointer' title='Search'></i></span>
|
||||
</div>
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<br />
|
||||
<div v-on:click='videoTab' class='channelTabOption'>
|
||||
VIDEOS
|
||||
</div>
|
||||
|
@ -26,7 +44,15 @@
|
|||
<div class='channelViewDescription'>
|
||||
<span v-html='description'></span>
|
||||
</div>
|
||||
<hr />
|
||||
<br />
|
||||
<div v-if='featuredChannels.length > 0' class='center'>
|
||||
<h2>Featured Channels</h2>
|
||||
<div class='profileEditPadding' v-for='channel in featuredChannels' v-on:click='goToChannel(channel.authorId)'>
|
||||
<img class='profileEdit' :src='channel.authorThumbnails[3].url' style='cursor: pointer;' />
|
||||
<div class='profileEditName'><span>{{channel.author}}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<hr />
|
||||
</div>
|
||||
|
|
|
@ -33,20 +33,20 @@
|
|||
</div>
|
||||
<div class='playlistVideoView'>
|
||||
<div v-for="video in videoList">
|
||||
<div class='playlistVideoOptions'>
|
||||
<i class="fas fa-ellipsis-v" onclick='showVideoOptions(this)'></i>
|
||||
<ul>
|
||||
<a v-on:click='openYouTube(video.id)' onclick='showVideoOptions(this.parentNode.previousSibling);'>
|
||||
<li>Open in YouTube</li>
|
||||
</a>
|
||||
<li v-on:click='copyYouTube(video.id)' onclick='showVideoOptions(this.parentNode.previousSibling);'>Copy YouTube Link</li>
|
||||
<a v-on:click='openInvidious(video.id)' onclick='showVideoOptions(this.parentNode.previousSibling);'>
|
||||
<li>Open in Invidious</li>
|
||||
</a>
|
||||
<li v-on:click='copyInvidious(video.id)' onclick='showVideoOptions(this.parentNode.previousSibling);'>Copy Invidious Link</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class='playlistVideo'>
|
||||
<div class='videoOptions'>
|
||||
<i class="fas fa-ellipsis-v" onclick='showVideoOptions(this)'></i>
|
||||
<ul>
|
||||
<a :href='video.youtubeUrl' onclick='showVideoOptions(this.parentNode.previousSibling);'>
|
||||
<li>Open in YouTube</li>
|
||||
</a>
|
||||
<li v-on:click='copy("youtube.com", video.id)' onclick='showVideoOptions(this.parentNode.previousSibling);'>Copy YouTube Link</li>
|
||||
<a :href='video.invidiousUrl' onclick='showVideoOptions(this.parentNode.previousSibling);'>
|
||||
<li>Open in Invidious</li>
|
||||
</a>
|
||||
<li v-on:click='copy("invidio.us", video.id)' onclick='showVideoOptions(this.parentNode.previousSibling);'>Copy Invidious Link</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class='playlistVideoThumbnail'>
|
||||
<img v-on:click='play(video.id)' :src='video.thumbnail' />
|
||||
<p v-on:click='play(video.id)' class='videoDuration'>{{video.duration}}</p>
|
||||
|
|
Loading…
Reference in New Issue