mirror of
https://github.com/JustinZeus/Wholphin.git
synced 2026-07-08 15:50:16 +02:00
Lots of UI tweaks
This commit is contained in:
parent
81d0279cce
commit
3109b4c003
10 changed files with 87 additions and 140 deletions
|
|
@ -27,6 +27,7 @@ import kotlin.contracts.ExperimentalContracts
|
||||||
import kotlin.contracts.InvocationKind
|
import kotlin.contracts.InvocationKind
|
||||||
import kotlin.contracts.contract
|
import kotlin.contracts.contract
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
import kotlin.time.Duration.Companion.minutes
|
import kotlin.time.Duration.Companion.minutes
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
|
|
@ -174,6 +175,9 @@ fun playOnClickSound(
|
||||||
val Duration.roundMinutes: Duration
|
val Duration.roundMinutes: Duration
|
||||||
get() = (this + 30.seconds).inWholeMinutes.minutes
|
get() = (this + 30.seconds).inWholeMinutes.minutes
|
||||||
|
|
||||||
|
val Duration.roundSeconds: Duration
|
||||||
|
get() = (this + 30.milliseconds).inWholeSeconds.seconds
|
||||||
|
|
||||||
val BaseItemDto.timeRemaining: Duration?
|
val BaseItemDto.timeRemaining: Duration?
|
||||||
get() =
|
get() =
|
||||||
userData?.playbackPositionTicks?.let {
|
userData?.playbackPositionTicks?.let {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -17,6 +18,7 @@ import androidx.tv.material3.Card
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.github.damontecres.dolphin.ui.AppColors
|
import com.github.damontecres.dolphin.ui.AppColors
|
||||||
|
import com.github.damontecres.dolphin.ui.roundSeconds
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -44,21 +46,23 @@ fun ChapterCard(
|
||||||
contentScale = ContentScale.Fit,
|
contentScale = ContentScale.Fit,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
)
|
||||||
Column(
|
Box(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.align(Alignment.BottomStart)
|
.align(Alignment.BottomStart)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.background(AppColors.TransparentBlack50),
|
.background(AppColors.TransparentBlack50),
|
||||||
) {
|
) {
|
||||||
name?.let {
|
Column(modifier = Modifier.padding(4.dp)) {
|
||||||
|
name?.let {
|
||||||
|
Text(
|
||||||
|
text = it,
|
||||||
|
)
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = position.roundSeconds.toString(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Text(
|
|
||||||
text = position.toString(),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -194,7 +194,7 @@ fun MovieDetailsContent(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.bringIntoViewRequester(bringIntoViewRequester)
|
.bringIntoViewRequester(bringIntoViewRequester)
|
||||||
.padding(bottom = 120.dp),
|
.padding(bottom = 56.dp),
|
||||||
) {
|
) {
|
||||||
MovieDetailsHeader(
|
MovieDetailsHeader(
|
||||||
movie = movie,
|
movie = movie,
|
||||||
|
|
|
||||||
|
|
@ -94,41 +94,40 @@ fun FocusedEpisodeHeader(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dto.overview?.let { overview ->
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val isFocused = interactionSource.collectIsFocusedAsState().value
|
val isFocused = interactionSource.collectIsFocusedAsState().value
|
||||||
val bgColor =
|
val bgColor =
|
||||||
if (isFocused) {
|
if (isFocused) {
|
||||||
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
|
MaterialTheme.colorScheme.onPrimary.copy(alpha = .4f)
|
||||||
} else {
|
} else {
|
||||||
Color.Unspecified
|
Color.Unspecified
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.background(bgColor, shape = RoundedCornerShape(8.dp))
|
||||||
|
.playSoundOnFocus(true)
|
||||||
|
.clickable(
|
||||||
|
enabled = true,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = LocalIndication.current,
|
||||||
|
) {
|
||||||
|
playOnClickSound(context)
|
||||||
|
overviewOnClick.invoke()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = dto.overview ?: "",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
maxLines = 3,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.background(bgColor, shape = RoundedCornerShape(8.dp))
|
.padding(8.dp)
|
||||||
.playSoundOnFocus(true)
|
.height(60.dp),
|
||||||
.clickable(
|
)
|
||||||
enabled = true,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = LocalIndication.current,
|
|
||||||
) {
|
|
||||||
playOnClickSound(context)
|
|
||||||
overviewOnClick.invoke()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = overview,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
|
||||||
maxLines = 3,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier =
|
|
||||||
Modifier
|
|
||||||
.padding(8.dp)
|
|
||||||
.height(60.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ fun SeriesOverviewContent(
|
||||||
LazyRow(
|
LazyRow(
|
||||||
state = state,
|
state = state,
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
contentPadding = PaddingValues(start = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.focusRestorer(firstItemFocusRequester)
|
.focusRestorer(firstItemFocusRequester)
|
||||||
|
|
|
||||||
|
|
@ -142,6 +142,7 @@ fun PlaybackControls(
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier.bringIntoViewRequester(bringIntoViewRequester),
|
modifier = modifier.bringIntoViewRequester(bringIntoViewRequester),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
verticalArrangement = Arrangement.spacedBy(0.dp),
|
||||||
) {
|
) {
|
||||||
SeekBar(
|
SeekBar(
|
||||||
player = playerControls,
|
player = playerControls,
|
||||||
|
|
@ -152,7 +153,7 @@ fun PlaybackControls(
|
||||||
intervals = seekBarIntervals,
|
intervals = seekBarIntervals,
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(vertical = 8.dp)
|
.padding(vertical = 0.dp)
|
||||||
.fillMaxWidth(.95f),
|
.fillMaxWidth(.95f),
|
||||||
)
|
)
|
||||||
Box(
|
Box(
|
||||||
|
|
@ -516,11 +517,11 @@ fun PlaybackButton(
|
||||||
containerColor = AppColors.TransparentBlack25,
|
containerColor = AppColors.TransparentBlack25,
|
||||||
focusedContainerColor = selectedColor,
|
focusedContainerColor = selectedColor,
|
||||||
),
|
),
|
||||||
contentPadding = PaddingValues(8.dp),
|
contentPadding = PaddingValues(4.dp),
|
||||||
modifier =
|
modifier =
|
||||||
modifier
|
modifier
|
||||||
.padding(8.dp)
|
.padding(4.dp)
|
||||||
.size(56.dp, 56.dp)
|
.size(44.dp, 44.dp)
|
||||||
.onFocusChanged { onControllerInteraction.invoke() },
|
.onFocusChanged { onControllerInteraction.invoke() },
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import androidx.compose.ui.focus.onFocusChanged
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.tv.material3.MaterialTheme
|
import androidx.tv.material3.MaterialTheme
|
||||||
import androidx.tv.material3.Text
|
import androidx.tv.material3.Text
|
||||||
|
|
@ -81,32 +82,33 @@ fun PlaybackOverlay(
|
||||||
// val chapterRowFocused = chapterInteractionSources.any { it.collectIsFocusedAsState().value }
|
// val chapterRowFocused = chapterInteractionSources.any { it.collectIsFocusedAsState().value }
|
||||||
var chapterRowFocused by remember { mutableStateOf(false) }
|
var chapterRowFocused by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
val titleTextStyle = MaterialTheme.typography.displaySmall
|
val titleTextSize = 28.sp
|
||||||
val subtitleTextStyle = MaterialTheme.typography.headlineMedium
|
val subtitleTextSize = 18.sp
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
|
|
||||||
val titleHeight =
|
val titleHeight =
|
||||||
if (title.isNotNullOrBlank()) with(density) { titleTextStyle.fontSize.toDp() } else 0.dp
|
if (title.isNotNullOrBlank()) with(density) { titleTextSize.toDp() } else 0.dp
|
||||||
val subtitleHeight =
|
val subtitleHeight =
|
||||||
if (subtitle.isNotNullOrBlank()) with(density) { subtitleTextStyle.fontSize.toDp() } else 0.dp
|
if (subtitle.isNotNullOrBlank()) with(density) { subtitleTextSize.toDp() } else 0.dp
|
||||||
|
|
||||||
// Calculate height based on content
|
// Calculate height based on content
|
||||||
// Base height (with or w/o chapters) + title + subtitle
|
// Base height (with or w/o chapters) + title + subtitle
|
||||||
// The extra 8dp is for padding between title, subtitle, and playback controls
|
// The extra 8dp is for padding between title, subtitle, and playback controls
|
||||||
// When chapter row is focused, the title/subtitle/playback controls will be hidden, but need extra height for the chapter images
|
// When chapter row is focused, the title/subtitle/playback controls will be hidden, but need extra height for the chapter images
|
||||||
val height by animateDpAsState(
|
val height by animateDpAsState(
|
||||||
(if (chapters.isNotEmpty()) 184.dp else 140.dp) +
|
96.dp +
|
||||||
(if (chapterRowFocused) 40.dp else 0.dp) +
|
(if (chapters.isNotEmpty()) 40.dp else 0.dp) +
|
||||||
|
(if (chapterRowFocused) 80.dp else 0.dp) +
|
||||||
(
|
(
|
||||||
if (!chapterRowFocused && title.isNotNullOrBlank()) {
|
if (!chapterRowFocused && title.isNotNullOrBlank()) {
|
||||||
titleHeight + 8.dp
|
titleHeight + 12.dp
|
||||||
} else {
|
} else {
|
||||||
0.dp
|
0.dp
|
||||||
}
|
}
|
||||||
) +
|
) +
|
||||||
(
|
(
|
||||||
if (!chapterRowFocused && subtitle.isNotNullOrBlank()) {
|
if (!chapterRowFocused && subtitle.isNotNullOrBlank()) {
|
||||||
subtitleHeight + 8.dp
|
subtitleHeight + 12.dp
|
||||||
} else {
|
} else {
|
||||||
0.dp
|
0.dp
|
||||||
}
|
}
|
||||||
|
|
@ -132,6 +134,7 @@ fun PlaybackOverlay(
|
||||||
modifier = Modifier,
|
modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.padding(start = 16.dp)
|
.padding(start = 16.dp)
|
||||||
|
|
@ -140,13 +143,15 @@ fun PlaybackOverlay(
|
||||||
title?.let {
|
title?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
style = titleTextStyle,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontSize = titleTextSize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
style = subtitleTextStyle,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontSize = subtitleTextSize,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ import com.github.damontecres.dolphin.util.ExceptionHandler
|
||||||
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
|
import com.github.damontecres.dolphin.util.TrackActivityPlaybackListener
|
||||||
import com.github.damontecres.dolphin.util.TrackSupport
|
import com.github.damontecres.dolphin.util.TrackSupport
|
||||||
import com.github.damontecres.dolphin.util.checkForSupport
|
import com.github.damontecres.dolphin.util.checkForSupport
|
||||||
import com.github.damontecres.dolphin.util.profile.PlaybackListener
|
import com.github.damontecres.dolphin.util.formatDateTime
|
||||||
import com.github.damontecres.dolphin.util.subtitleMimeTypes
|
import com.github.damontecres.dolphin.util.subtitleMimeTypes
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
|
@ -57,15 +57,7 @@ data class StreamDecision(
|
||||||
val itemId: UUID,
|
val itemId: UUID,
|
||||||
val type: TranscodeType,
|
val type: TranscodeType,
|
||||||
val url: String,
|
val url: String,
|
||||||
) {
|
)
|
||||||
val mediaItem: MediaItem
|
|
||||||
get() =
|
|
||||||
MediaItem
|
|
||||||
.Builder()
|
|
||||||
.setMediaId(itemId.toString())
|
|
||||||
.setUri(url.toUri())
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class PlaybackViewModel
|
class PlaybackViewModel
|
||||||
|
|
@ -112,25 +104,26 @@ class PlaybackViewModel
|
||||||
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content
|
val base = item?.data ?: api.userLibraryApi.getItem(itemId).content
|
||||||
dto = base
|
dto = base
|
||||||
|
|
||||||
val title = base.name
|
val title =
|
||||||
|
if (base.type == BaseItemKind.EPISODE) {
|
||||||
|
base.seriesName
|
||||||
|
} else {
|
||||||
|
base.name
|
||||||
|
}
|
||||||
val subtitle =
|
val subtitle =
|
||||||
if (base.type == BaseItemKind.EPISODE) {
|
if (base.type == BaseItemKind.EPISODE) {
|
||||||
val season = base.parentIndexNumber?.toString()?.padStart(2, '0')
|
val season = base.parentIndexNumber?.toString()?.padStart(2, '0')
|
||||||
val episode = base.indexNumber?.toString()?.padStart(2, '0')
|
val episode = base.indexNumber?.toString()?.padStart(2, '0')
|
||||||
// TODO multi episode support
|
// TODO multi episode support
|
||||||
if (season != null && episode != null) {
|
buildList {
|
||||||
buildString {
|
if (season != null && episode != null) {
|
||||||
if (base.seriesName != null) {
|
add("S${season}E$episode")
|
||||||
append(base.seriesName)
|
|
||||||
append(" - ")
|
|
||||||
}
|
|
||||||
append("S${season}E$episode")
|
|
||||||
}
|
}
|
||||||
} else {
|
add(base.name)
|
||||||
null
|
add(base.premiereDate?.let { formatDateTime(it) })
|
||||||
}
|
}.filterNotNull().joinToString(" - ")
|
||||||
} else {
|
} else {
|
||||||
null
|
base.productionYear?.toString()
|
||||||
}
|
}
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
this@PlaybackViewModel.title.value = title
|
this@PlaybackViewModel.title.value = title
|
||||||
|
|
@ -186,7 +179,6 @@ class PlaybackViewModel
|
||||||
TrackActivityPlaybackListener(api, itemId, player)
|
TrackActivityPlaybackListener(api, itemId, player)
|
||||||
addCloseable { activityListener.release() }
|
addCloseable { activityListener.release() }
|
||||||
player.addListener(activityListener)
|
player.addListener(activityListener)
|
||||||
player.addListener(PlaybackListener())
|
|
||||||
changeStreams(
|
changeStreams(
|
||||||
itemId,
|
itemId,
|
||||||
audioIndex,
|
audioIndex,
|
||||||
|
|
|
||||||
|
|
@ -1,60 +0,0 @@
|
||||||
package com.github.damontecres.dolphin.util.profile
|
|
||||||
|
|
||||||
import androidx.media3.common.MediaItem
|
|
||||||
import androidx.media3.common.PlaybackException
|
|
||||||
import androidx.media3.common.Player
|
|
||||||
import androidx.media3.common.Tracks
|
|
||||||
import androidx.media3.common.text.CueGroup
|
|
||||||
import com.github.damontecres.dolphin.ui.indexOfFirstOrNull
|
|
||||||
import com.github.damontecres.dolphin.util.TrackSupportReason
|
|
||||||
import com.github.damontecres.dolphin.util.TrackType
|
|
||||||
import com.github.damontecres.dolphin.util.checkForSupport
|
|
||||||
import timber.log.Timber
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
class PlaybackListener : Player.Listener {
|
|
||||||
var mediaIndexSubtitlesActivated = -1
|
|
||||||
var currentPlaylistIndex = -2
|
|
||||||
|
|
||||||
override fun onCues(cueGroup: CueGroup) {
|
|
||||||
val subtitles = cueGroup.cues.ifEmpty { null }
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onTracksChanged(tracks: Tracks) {
|
|
||||||
val trackInfo = checkForSupport(tracks)
|
|
||||||
Timber.v("Track info: $trackInfo")
|
|
||||||
val audioTracks =
|
|
||||||
trackInfo
|
|
||||||
.filter { it.type == TrackType.AUDIO && it.supported == TrackSupportReason.HANDLED }
|
|
||||||
val audioIndex = audioTracks.indexOfFirstOrNull { it.selected }
|
|
||||||
val audioOptions =
|
|
||||||
audioTracks.map { it.labels.joinToString(", ").ifBlank { "Default" } }
|
|
||||||
val captions =
|
|
||||||
trackInfo.filter { it.type == TrackType.TEXT && it.supported == TrackSupportReason.HANDLED }
|
|
||||||
|
|
||||||
// TODO user preference
|
|
||||||
val captionsByDefault = true
|
|
||||||
if (captionsByDefault && captions.isNotEmpty()) {
|
|
||||||
// TODO Captions will be empty when transitioning to new media item
|
|
||||||
// Only want to activate subtitles once in case the user turns them off
|
|
||||||
mediaIndexSubtitlesActivated = currentPlaylistIndex
|
|
||||||
val languageCode = Locale.getDefault().language
|
|
||||||
captions.indexOfFirstOrNull { it.format.language == languageCode }?.let {
|
|
||||||
Timber.v("Found default subtitle track for $languageCode: $it")
|
|
||||||
// if (toggleSubtitles(player, null, it)) {
|
|
||||||
// subtitleIndex = it
|
|
||||||
// }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onMediaItemTransition(
|
|
||||||
mediaItem: MediaItem?,
|
|
||||||
reason: Int,
|
|
||||||
) {
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
|
||||||
Timber.e(error, "Playback error")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
<resources>
|
<resources>
|
||||||
|
|
||||||
<style name="Theme.Dolphin" parent="Theme.AppCompat.DayNight.NoActionBar" />
|
<style name="Theme.Dolphin" parent="Theme.AppCompat.DayNight.NoActionBar">
|
||||||
</resources>
|
<item name="android:colorBackground">@android:color/background_dark</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue