Compare commits

...

33 Commits

Author SHA1 Message Date
Niels van Velzen
9129f3b67d Fix DestinationFragmentView not replacing existing fragment on resume
(cherry picked from commit fdd079d32b)
2025-06-06 21:39:10 +02:00
Niels van Velzen
8866f8a671 Add missing action for AndroidMediaService
(cherry picked from commit 7bc2af7ff7)
2025-05-28 19:13:42 +02:00
renovate[bot]
57e3827cec Update jellyfin-androidx-media to 1.6.1+1
(cherry picked from commit 0ecec926e8)
2025-05-28 19:12:49 +02:00
renovate[bot]
51acba94b3 Update androidx.media3 to v1.6.1
(cherry picked from commit 084061df65)
2025-05-28 19:11:16 +02:00
Florian Dreier
ac69b905aa Fix crash on startup (#4640)
* Fix crash on startup

* Changed condition evaluation order

(cherry picked from commit a78db2701a)
2025-05-28 19:11:02 +02:00
Niels van Velzen
82539eff8f Fix crash in CustomPlaybackOverlayFragment.prepareChannelAdapter when Live TV channels are not loaded
(cherry picked from commit b22d45816e)
2025-05-28 19:10:52 +02:00
Niels van Velzen
6959bc66b5 Fix "favorites" playlist not showing when the user has no playlists
(cherry picked from commit 3067f07b2e)
2025-05-28 19:10:42 +02:00
Niels van Velzen
8a7c786fb6 Do not set audio session to pipeline on create
(cherry picked from commit 9d87030c1b)
2025-04-11 18:32:38 +02:00
Niels van Velzen
7ca6934c35 Fix experimental audio night mode feature crashing the app
(cherry picked from commit 1574220fb4)
2025-04-11 18:32:37 +02:00
renovate[bot]
4bdfd5de41 Update dependency org.jellyfin.sdk:jellyfin-core to v1.6.8
(cherry picked from commit 7636916166)
2025-04-09 18:58:05 +02:00
renovate[bot]
c15b55b0da Update androidx.media3 to v1.6.0
(cherry picked from commit 2aec7b32fd)
2025-04-09 18:56:36 +02:00
Niels van Velzen
5e9e747020 Fix ItemRowAdapter failures never logged
(cherry picked from commit a8b5209b5a)
2025-04-09 18:55:23 +02:00
Niels van Velzen
7c71efd1a0 Fix crash when trying to update detached AudioNowPlayingFragment
(cherry picked from commit 6354fc3bcd)
2025-04-09 18:55:14 +02:00
Niels van Velzen
958a388048 Add custom connectivity checker for Coil that always claims to be connected
(cherry picked from commit 99a8f78623)
2025-03-11 22:00:11 +01:00
Niels van Velzen
4eca5b7bd8 Enable logging for Coil
(cherry picked from commit f0886fccc3)
2025-03-11 22:00:11 +01:00
Niels van Velzen
71bd5cc74d Fix filtering not working in collections view (v2)
(cherry picked from commit a4d8295ecd)
2025-03-03 11:53:44 +01:00
Niels van Velzen
70c5d161f9 Revert "Fix filtering not working in collections view"
This reverts commit 27c430e864.

(cherry picked from commit 71a205dc5a)
2025-03-02 20:55:53 +01:00
Niels van Velzen
21346ab47b Fix crash when switching subtitle track to non existent index
(cherry picked from commit 612f074bb5)
2025-03-01 20:59:32 +01:00
Niels van Velzen
3d305bdcb2 Fix filtering not working in collections view
(cherry picked from commit 27c430e864)
2025-03-01 20:59:31 +01:00
Lukas Fichtner
61d9539dda fix progress time calculation
(cherry picked from commit 2a49b8ba50)
2025-03-01 20:59:18 +01:00
renovate[bot]
379d3433c4 Update coil to v3.1.0
(cherry picked from commit 6418140797)
2025-03-01 20:58:55 +01:00
renovate[bot]
91aae29e39 Update dependency org.jellyfin.sdk:jellyfin-core to v1.6.6
(cherry picked from commit 734dc04f35)
2025-03-01 20:58:38 +01:00
renovate[bot]
80b2a39e24 Update dependency org.jellyfin.sdk:jellyfin-core to v1.6.5
(cherry picked from commit 9882ffb7b7)
2025-03-01 20:58:27 +01:00
renovate[bot]
832ca67a02 Update dependency org.jellyfin.sdk:jellyfin-core to v1.6.4
(cherry picked from commit db7d7f9993)
2025-03-01 20:57:51 +01:00
smarthome-abc
52885af7b9 Trust user added CA certificates (#4397)
* Update AndroidManifest.xml

* Create network_security_config.xml

* Allow user CA certs: Fix PR lint issues

* Allow user CA certs: Add a new line

---------

Co-authored-by: ABC <ABC@Macbook.local>
(cherry picked from commit f6f324cfa9)
2025-03-01 20:57:40 +01:00
Niels van Velzen
8350fae372 Fix crash when favoriting Live TV program while loading additional channels in the guide
(cherry picked from commit 54a70e9e69)
2025-01-06 12:36:38 +01:00
Niels van Velzen
0912212887 Prevent seeking from crashing app if video info is not initialized yet
(cherry picked from commit 38aa2f1cb3)
2025-01-06 12:36:37 +01:00
Catherine Gilbert
f7839968b2 Dismiss playback actions popups on fragment detach (#4365)
* Dismiss playback actions popups on fragment detach

* Update CONTRIBUTORS.md

(cherry picked from commit d91d873c61)
2025-01-06 12:36:37 +01:00
Niels van Velzen
3ca8667d32 Fix unable to focus toolbar in item details screen
(cherry picked from commit b6b2c0335f)
2025-01-06 12:36:36 +01:00
Niels van Velzen
af782baf86 Fix unable to focus toolbar in server selection screen
(cherry picked from commit eb8d7f8392)
2025-01-06 12:36:36 +01:00
Niels van Velzen
920ddccc80 Fix app crashing when playback reporting fails
(cherry picked from commit c86573b671)
2025-01-06 12:36:35 +01:00
Niels van Velzen
71308f6ddc Fix Live TV fail-over not working
(cherry picked from commit 3385058775)
2025-01-06 12:36:35 +01:00
Niels van Velzen
db22ecd787 Enable audio focus for music playback
(cherry picked from commit 0533d54bbd)
2025-01-06 12:36:35 +01:00
30 changed files with 266 additions and 103 deletions

View File

@@ -14,6 +14,7 @@
- [mohd-akram](https://github.com/mohd-akram)
- [3l0w](https://github.com/3l0w)
- [MajMongoose](https://github.com/majmongoose)
- [Olaren15](https://github.com/Olaren15)
# Emby Contributors

View File

@@ -21,4 +21,8 @@
<issue id="Typos" severity="ignore" />
<issue id="UnknownNullness" severity="informational" />
<issue id="UnusedIds" severity="ignore" />
<issue id="UnusedAttribute">
<ignore regexp="usesCleartextTraffic" />
<ignore regexp="networkSecurityConfig" />
</issue>
</lint>

View File

@@ -49,7 +49,8 @@
android:largeHeap="true"
android:supportsRtl="true"
android:theme="@style/Theme.Jellyfin"
android:usesCleartextTraffic="true">
android:usesCleartextTraffic="true"
android:networkSecurityConfig="@xml/network_security_config">
<!-- Legacy service required for AccountManagerMigration -->
<service

View File

@@ -3,11 +3,13 @@ package org.jellyfin.androidtv.di
import android.content.Context
import android.os.Build
import coil3.ImageLoader
import coil3.annotation.ExperimentalCoilApi
import coil3.gif.AnimatedImageDecoder
import coil3.gif.GifDecoder
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import coil3.serviceLoaderEnabled
import coil3.svg.SvgDecoder
import coil3.util.Logger
import org.jellyfin.androidtv.BuildConfig
import org.jellyfin.androidtv.auth.repository.ServerRepository
import org.jellyfin.androidtv.auth.repository.UserRepository
@@ -45,6 +47,8 @@ import org.jellyfin.androidtv.util.KeyProcessor
import org.jellyfin.androidtv.util.MarkdownRenderer
import org.jellyfin.androidtv.util.PlaybackHelper
import org.jellyfin.androidtv.util.apiclient.ReportingHelper
import org.jellyfin.androidtv.util.coil.CoilTimberLogger
import org.jellyfin.androidtv.util.coil.createCoilConnectivityChecker
import org.jellyfin.androidtv.util.sdk.SdkPlaybackHelper
import org.jellyfin.androidtv.util.sdk.legacy
import org.jellyfin.apiclient.AppInfo
@@ -106,8 +110,12 @@ val appModule = module {
single {
ImageLoader.Builder(androidContext()).apply {
serviceLoaderEnabled(false)
logger(CoilTimberLogger(if (BuildConfig.DEBUG) Logger.Level.Warn else Logger.Level.Error))
components {
add(OkHttpNetworkFetcherFactory())
@OptIn(ExperimentalCoilApi::class)
add(OkHttpNetworkFetcherFactory(connectivityChecker = ::createCoilConnectivityChecker))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) add(AnimatedImageDecoder.Factory())
else add(GifDecoder.Factory())
add(SvgDecoder.Factory())

View File

@@ -17,6 +17,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
@@ -50,7 +51,9 @@ import org.jellyfin.sdk.model.api.ImageType
import org.koin.compose.koinInject
@Composable
fun NowPlayingComposable() {
fun NowPlayingComposable(
onFocusableChange: (focusable: Boolean) -> Unit,
) {
val playbackManager = koinInject<PlaybackManager>()
val navigationRepository = koinInject<NavigationRepository>()
val imageHelper = koinInject<ImageHelper>()
@@ -59,6 +62,8 @@ fun NowPlayingComposable() {
val item = entry?.run { baseItemFlow.collectAsState(baseItem) }?.value
val progress = rememberPlayerProgress(playbackManager)
LaunchedEffect(item == null) { onFocusableChange(item != null) }
AnimatedVisibility(
visible = item != null,
enter = fadeIn(),
@@ -153,5 +158,11 @@ class NowPlayingView @JvmOverloads constructor(
defStyle: Int = 0
) : AbstractComposeView(context, attrs, defStyle) {
@Composable
override fun Content() = NowPlayingComposable()
override fun Content() = NowPlayingComposable(
// Workaround for older Android versions unable to find focus in our toolbar view when the NowPlayingView is added but inactive
onFocusableChange = { focusable ->
isFocusable = focusable
descendantFocusability = if (focusable) FOCUS_AFTER_DESCENDANTS else FOCUS_BLOCK_DESCENDANTS
}
)
}

View File

@@ -415,12 +415,6 @@ object BrowsingUtils {
recursive = true,
)
CollectionType.BOXSETS -> baseRequest.copy(
includeItemTypes = setOf(BaseItemKind.BOX_SET),
parentId = null,
recursive = true,
)
CollectionType.MUSIC -> baseRequest.copy(
includeItemTypes = setOf(BaseItemKind.MUSIC_ALBUM),
recursive = true,

View File

@@ -16,6 +16,7 @@ import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentTransaction
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.navigation.NavigationAction
import timber.log.Timber
import java.util.Stack
private class HistoryEntry(
@@ -151,12 +152,16 @@ class DestinationFragmentView @JvmOverloads constructor(
// Attach or add next fragment
if (fragment.isDetached) attach(fragment)
else add(container.id, fragment, FRAGMENT_TAG_CONTENT)
else replace(container.id, fragment, FRAGMENT_TAG_CONTENT)
}
// Commit
if (fragmentManager.isStateSaved) transaction.commitNowAllowingStateLoss()
else transaction.commitNow()
if (fragmentManager.isDestroyed) {
Timber.w("FragmentManager is already destroyed")
} else if (fragmentManager.isStateSaved) {
transaction.commitAllowingStateLoss()
} else {
transaction.commit()
}
}
override fun onSaveInstanceState(): Parcelable {

View File

@@ -42,11 +42,11 @@ public class ChannelCardView extends FrameLayout {
.append(DateTimeExtensionsKt.getTimeFormatter(getContext()).format(program.getEndDate()))
);
if (program.getStartDate().isAfter(LocalDateTime.now()) && program.getEndDate().isBefore(LocalDateTime.now())) {
if (program.getStartDate().isBefore(LocalDateTime.now()) && program.getEndDate().isAfter(LocalDateTime.now())) {
Duration duration = Duration.between(program.getStartDate(), program.getEndDate());
Duration progress = Duration.between(program.getStartDate(), LocalDateTime.now());
binding.progress.setProgress((int) (progress.getSeconds() / duration.getSeconds() * 100));
binding.progress.setProgress((int) ((progress.getSeconds() / (double) duration.getSeconds()) * 100));
} else {
binding.progress.setProgress(0);
}

View File

@@ -719,6 +719,8 @@ public class ItemRowAdapter extends MutableObjectAdapter<Object> {
}
protected void notifyRetrieveFinished(@Nullable Exception exception) {
if (exception != null) Timber.w(exception, "Failed to retrieve items");
setCurrentlyRetrieving(false);
if (mRetrieveFinishedListener != null && mRetrieveFinishedListener.getActive()) {
if (exception == null) mRetrieveFinishedListener.onResponse();

View File

@@ -552,7 +552,7 @@ fun ItemRowAdapter.retrieveItems(
},
)
if (response.items.isEmpty()) removeRow()
if (itemsLoaded == 0) removeRow()
}.fold(
onSuccess = { notifyRetrieveFinished() },
onFailure = { error -> notifyRetrieveFinished(error as? Exception) }

View File

@@ -235,7 +235,9 @@ public class LiveTvGuideFragment extends Fragment implements LiveTvGuide, View.O
public void refreshFavorite(UUID channelId){
for (int i = 0; i < mChannels.getChildCount(); i++) {
GuideChannelHeader gch = (GuideChannelHeader)mChannels.getChildAt(i);
View child = mChannels.getChildAt(i);
if (!(child instanceof GuideChannelHeader)) continue;
GuideChannelHeader gch = (GuideChannelHeader) child;
if (gch.getChannel().getId().equals(channelId.toString()))
gch.refreshFavorite();
}

View File

@@ -310,6 +310,7 @@ public class AudioNowPlayingFragment extends Fragment {
}
private void updateButtons() {
if (getActivity() == null) return;
Timber.d("Updating buttons");
boolean playing = mediaManager.getValue().isPlayingAudio();
requireActivity().runOnUiThread(new Runnable() {

View File

@@ -1281,8 +1281,10 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
private void prepareChannelAdapter() {
// create quick channel change row
TvManager.loadAllChannels(this, response -> {
List<BaseItemDto> channels = TvManager.getAllChannels();
if (channels == null) return null;
ArrayObjectAdapter channelAdapter = new ArrayObjectAdapter(new ChannelCardPresenter());
channelAdapter.addAll(0, TvManager.getAllChannels());
channelAdapter.addAll(0, channels);
if (mChapterRow != null) mPopupRowAdapter.remove(mChapterRow);
mChapterRow = new ListRow(new HeaderItem(requireContext().getString(R.string.channels)), channelAdapter);
mPopupRowAdapter.add(mChapterRow);

View File

@@ -51,6 +51,8 @@ import java.util.List;
import kotlin.Lazy;
import timber.log.Timber;
import java.time.Duration;
public class PlaybackController implements PlaybackControllerNotifiable {
// Frequency to report playback progress
private final static long PROGRESS_REPORTING_INTERVAL = TimeUtils.secondsToMillis(3);
@@ -512,7 +514,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
internalOptions.setMediaSourceId(currentMediaSource.getId());
}
DeviceProfile internalProfile = new ExoPlayerProfile(
isLiveTv && !userPreferences.getValue().get(UserPreferences.Companion.getLiveTvDirectPlayEnabled()),
!internalOptions.getEnableDirectStream(),
userPreferences.getValue().get(UserPreferences.Companion.getAc3Enabled()),
userPreferences.getValue().get(UserPreferences.Companion.getAudioBehaviour()) == AudioBehavior.DOWNMIX_TO_STEREO
);
@@ -901,6 +903,8 @@ public class PlaybackController implements PlaybackControllerNotifiable {
// set seekPosition so real position isn't used until playback starts again
mSeekPosition = pos;
if (mCurrentStreamInfo == null) return;
// rebuild the stream
// if an older device uses exoplayer to play a transcoded stream but falls back to the generic http stream instead of hls, rebuild the stream
if (!mVideoManager.isSeekable()) {
@@ -978,7 +982,7 @@ public class PlaybackController implements PlaybackControllerNotifiable {
BaseItemDto program = updatedChannel.getCurrentProgram();
if (program != null) {
mCurrentProgramEnd = program.getEndDate();
mCurrentProgramStart = program.getPremiereDate();
mCurrentProgramStart = program.getStartDate();
if (mFragment != null) mFragment.updateDisplay();
}
return null;
@@ -987,11 +991,10 @@ public class PlaybackController implements PlaybackControllerNotifiable {
}
private long getRealTimeProgress() {
long progress = Instant.now().toEpochMilli();
if (mCurrentProgramStart != null) {
progress -= mCurrentProgramStart.toInstant(ZoneOffset.UTC).toEpochMilli();
return Duration.between(mCurrentProgramStart, LocalDateTime.now()).toMillis();
}
return progress;
return 0;
}
private long getTimeShiftedProgress() {

View File

@@ -75,7 +75,7 @@ fun PlaybackController.setSubtitleIndex(index: Int, force: Boolean = false) {
play(mCurrentPosition, index)
} else {
val mediaSource = currentMediaSource
val stream = mediaSource.mediaStreams?.first { it.type == MediaStreamType.SUBTITLE && it.index == index }
val stream = mediaSource.mediaStreams?.firstOrNull { it.type == MediaStreamType.SUBTITLE && it.index == index }
if (stream == null) {
Timber.w("Failed to find correct media stream")
return setSubtitleIndex(-1)
@@ -108,7 +108,7 @@ fun PlaybackController.setSubtitleIndex(index: Int, force: Boolean = false) {
.filter { it.type == MediaStreamType.SUBTITLE }
.filter { it.deliveryMethod == SubtitleDeliveryMethod.EMBED || it.deliveryMethod == SubtitleDeliveryMethod.HLS }
.indexOf(stream)
.takeIf { index -> index != -1 }
.takeIf { it != -1 }
if (localIndex == null) {
Timber.w("Failed to find local subtitle index")

View File

@@ -31,6 +31,7 @@ import androidx.media3.datasource.DefaultDataSource;
import androidx.media3.datasource.DefaultHttpDataSource;
import androidx.media3.exoplayer.DefaultRenderersFactory;
import androidx.media3.exoplayer.ExoPlayer;
import androidx.media3.exoplayer.analytics.AnalyticsListener;
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory;
import androidx.media3.exoplayer.trackselection.DefaultTrackSelector;
import androidx.media3.exoplayer.util.EventLogger;
@@ -90,7 +91,14 @@ public class VideoManager {
}
// Volume normalisation (audio night mode).
if (nightModeEnabled) enableAudioNightMode(mExoPlayer.getAudioSessionId());
if (nightModeEnabled) {
mExoPlayer.addAnalyticsListener(new AnalyticsListener() {
@Override
public void onAudioSessionIdChanged(AnalyticsListener.EventTime eventTime, int audioSessionId) {
enableAudioNightMode(audioSessionId);
}
});
}
mExoPlayerView = view.findViewById(R.id.exoPlayerView);
mExoPlayerView.setPlayer(mExoPlayer);
@@ -621,6 +629,10 @@ public class VideoManager {
}
private void enableAudioNightMode(int audioSessionId) {
Timber.i("Enabling audio night mode for session %d", audioSessionId);
if (mEqualizer != null) mEqualizer.release();
if (mDynamicsProcessing != null) mDynamicsProcessing.release();
// Equaliser variables.
short eqDefault = (short) 0;
short eqSmallBoost = (short) 2;

View File

@@ -109,6 +109,13 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
protected void onDetachedFromHost() {
mHandler.removeCallbacks(mRefreshEndTime);
mHandler.removeCallbacks(mRefreshViewVisibility);
closedCaptionsAction.removePopup();
playbackSpeedAction.dismissPopup();
selectAudioAction.dismissPopup();
selectQualityAction.dismissPopup();
zoomAction.dismissPopup();
super.onDetachedFromHost();
}
@@ -169,6 +176,7 @@ public class CustomPlaybackTransportControlGlue extends PlaybackTransportControl
super.onBindRowViewHolder(vh, item);
vh.setOnKeyListener(CustomPlaybackTransportControlGlue.this);
}
@Override
protected void onUnbindRowViewHolder(RowPresenter.ViewHolder vh) {
super.onUnbindRowViewHolder(vh);

View File

@@ -17,6 +17,8 @@ class ClosedCaptionsAction(
context: Context,
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
) : CustomAction(context, customPlaybackTransportControlGlue) {
private var popup: PopupMenu? = null
init {
initializeWithIcon(R.drawable.ic_select_subtitle)
}
@@ -34,7 +36,8 @@ class ClosedCaptionsAction(
}
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
PopupMenu(context, view, Gravity.END).apply {
removePopup()
popup = PopupMenu(context, view, Gravity.END).apply {
with(menu) {
var order = 0
add(0, -1, order++, context.getString(R.string.lbl_none)).apply {
@@ -51,11 +54,19 @@ class ClosedCaptionsAction(
setGroupCheckable(0, true, false)
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
setOnDismissListener {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
popup = null
}
setOnMenuItemClickListener { item ->
playbackController.setSubtitleIndex(item.itemId)
true
}
}.show()
}
popup?.show()
}
fun removePopup() {
popup?.dismiss()
}
}

View File

@@ -18,6 +18,7 @@ class PlaybackSpeedAction(
) : CustomAction(context, customPlaybackTransportControlGlue) {
private val speedController = VideoSpeedController(playbackController)
private val speeds = VideoSpeedController.SpeedSteps.entries.toTypedArray()
private var popup: PopupMenu? = null
init {
initializeWithIcon(R.drawable.ic_playback_speed)
@@ -30,17 +31,20 @@ class PlaybackSpeedAction(
view: View,
) {
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
val speedMenu = populateMenu(context, view, speedController)
dismissPopup()
popup = populateMenu(context, view, speedController)
speedMenu.setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
popup?.setOnDismissListener {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
popup = null
}
speedMenu.setOnMenuItemClickListener { menuItem ->
popup?.setOnMenuItemClickListener { menuItem ->
speedController.currentSpeed = speeds[menuItem.itemId]
speedMenu.dismiss()
true
}
speedMenu.show()
popup?.show()
}
private fun populateMenu(
@@ -57,4 +61,7 @@ class PlaybackSpeedAction(
menu.getItem(speeds.indexOf(speedController.currentSpeed)).isChecked = true
}
fun dismissPopup() {
popup?.dismiss()
}
}

View File

@@ -8,7 +8,6 @@ import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.ui.playback.PlaybackController
import org.jellyfin.androidtv.ui.playback.PlaybackManager
import org.jellyfin.androidtv.ui.playback.overlay.CustomPlaybackTransportControlGlue
import org.jellyfin.androidtv.ui.playback.overlay.LeanbackOverlayFragment
import org.jellyfin.androidtv.ui.playback.overlay.VideoPlayerAdapter
class SelectAudioAction(
@@ -16,6 +15,8 @@ class SelectAudioAction(
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
private val playbackManager: PlaybackManager,
) : CustomAction(context, customPlaybackTransportControlGlue) {
private var popup: PopupMenu? = null
init {
initializeWithIcon(R.drawable.ic_select_audio)
}
@@ -31,7 +32,8 @@ class SelectAudioAction(
?: return
val currentAudioIndex = playbackController.audioStreamIndex
PopupMenu(context, view, Gravity.END).apply {
dismissPopup()
popup = PopupMenu(context, view, Gravity.END).apply {
with(menu) {
for (track in audioTracks) {
add(0, track.index, track.index, track.displayTitle).apply {
@@ -41,11 +43,19 @@ class SelectAudioAction(
setGroupCheckable(0, true, false)
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
setOnDismissListener {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
popup = null
}
setOnMenuItemClickListener { item ->
playbackController.switchAudioStream(item.itemId)
true
}
}.show()
}
popup?.show()
}
fun dismissPopup() {
popup?.dismiss()
}
}

View File

@@ -20,6 +20,7 @@ class SelectQualityAction(
private val previousQualitySelection = userPreferences[UserPreferences.maxBitrate]
private val qualityController = VideoQualityController(previousQualitySelection, userPreferences)
private val qualityProfiles = getQualityProfiles(context)
private var popup: PopupMenu? = null
init {
initializeWithIcon(R.drawable.ic_select_quality)
@@ -32,7 +33,8 @@ class SelectQualityAction(
view: View,
) {
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
PopupMenu(context, view, Gravity.END).apply {
dismissPopup()
popup = PopupMenu(context, view, Gravity.END).apply {
qualityProfiles.values.forEachIndexed { i, selected ->
menu.add(0, i, i, selected)
}
@@ -45,13 +47,21 @@ class SelectQualityAction(
}
}
setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
setOnDismissListener {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
popup = null
}
setOnMenuItemClickListener { menuItem ->
qualityController.currentQuality = qualityProfiles.keys.elementAt(menuItem.itemId)
playbackController.refreshStream()
dismiss()
true
}
}.show()
}
popup?.show()
}
fun dismissPopup() {
popup?.dismiss()
}
}

View File

@@ -3,6 +3,7 @@ package org.jellyfin.androidtv.ui.playback.overlay.action
import android.content.Context
import android.view.Gravity
import android.view.View
import android.widget.PopupMenu
import org.jellyfin.androidtv.R
import org.jellyfin.androidtv.preference.constant.ZoomMode
import org.jellyfin.androidtv.ui.playback.PlaybackController
@@ -14,6 +15,8 @@ class ZoomAction(
context: Context,
customPlaybackTransportControlGlue: CustomPlaybackTransportControlGlue,
) : CustomAction(context, customPlaybackTransportControlGlue) {
private var popup: PopupMenu? = null
init {
initializeWithIcon(R.drawable.ic_aspect_ratio)
}
@@ -25,7 +28,8 @@ class ZoomAction(
view: View,
) {
videoPlayerAdapter.leanbackOverlayFragment.setFading(false)
val popup = popupMenu(context, view, Gravity.END) {
dismissPopup()
popup = popupMenu(context, view, Gravity.END) {
item(context.getString(R.string.lbl_fit)) {
playbackController.setZoom(ZoomMode.FIT)
}.apply {
@@ -44,8 +48,15 @@ class ZoomAction(
isChecked = playbackController.zoomMode == ZoomMode.STRETCH
}
}
popup.menu.setGroupCheckable(0, true, true)
popup.setOnDismissListener { videoPlayerAdapter.leanbackOverlayFragment.setFading(true) }
popup.show()
popup?.menu?.setGroupCheckable(0, true, true)
popup?.setOnDismissListener {
videoPlayerAdapter.leanbackOverlayFragment.setFading(true)
popup = null
}
popup?.show()
}
fun dismissPopup() {
popup?.dismiss()
}
}

View File

@@ -0,0 +1,21 @@
package org.jellyfin.androidtv.util.coil
import android.util.Log
import coil3.util.Logger
import timber.log.Timber
class CoilTimberLogger(
override var minLevel: Logger.Level = Logger.Level.Debug,
) : Logger {
override fun log(tag: String, level: Logger.Level, message: String?, throwable: Throwable?) {
val priority = when (level) {
Logger.Level.Verbose -> Log.VERBOSE
Logger.Level.Debug -> Log.DEBUG
Logger.Level.Info -> Log.INFO
Logger.Level.Warn -> Log.WARN
Logger.Level.Error -> Log.ERROR
}
Timber.tag("CoilTimberLogger.$tag").log(priority, throwable, message)
}
}

View File

@@ -0,0 +1,14 @@
package org.jellyfin.androidtv.util.coil
import android.content.Context
import coil3.annotation.ExperimentalCoilApi
import coil3.network.ConnectivityChecker
/**
* Coil uses a "connectivity checker" to skip the network if it thinks there is no available connection. In Android this is sometimes
* detected by pinging a known host (e.g. google.com) which might fail for users that blackhole these hosts or only use the app on their
* local network. This implementation of the connectivity checker will always return `true` to force a network request to be made.
*/
@Suppress("UNUSED_PARAMETER")
@ExperimentalCoilApi
fun createCoilConnectivityChecker(context: Context) = ConnectivityChecker { true }

View File

@@ -70,7 +70,9 @@
<androidx.compose.ui.platform.ComposeView
android:id="@+id/notifications"
android:layout_width="wrap_content"
android:layout_height="wrap_content" />
android:layout_height="wrap_content"
android:descendantFocusability="blocksDescendants"
android:focusable="false" />
<LinearLayout
android:layout_width="match_parent"

View File

@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config xmlns:tools="http://schemas.android.com/tools" >
<base-config cleartextTrafficPermitted="true" tools:ignore="InsecureBaseConfiguration" >
<trust-anchors>
<certificates src="system" />
<certificates src="user" tools:ignore="AcceptsUserCertificates" />
</trust-anchors>
</base-config>
</network-security-config>

View File

@@ -16,7 +16,7 @@ androidx-core = "1.15.0"
androidx-fragment = "1.8.5"
androidx-leanback = "1.1.0-rc01"
androidx-lifecycle = "2.8.7"
androidx-media3 = "1.5.1"
androidx-media3 = "1.6.1"
androidx-preference = "1.2.1"
androidx-recyclerview = "1.3.2"
androidx-startup = "1.2.0"
@@ -24,12 +24,12 @@ androidx-tv = "1.0.0"
androidx-tvprovider = "1.1.0-alpha01"
androidx-window = "1.3.0"
androidx-work = "2.10.0"
coil = "3.0.4"
coil = "3.1.0"
detekt = "1.23.7"
java-jdk = "21"
jellyfin-androidx-media = "1.5.0+1"
jellyfin-androidx-media = "1.6.1+1"
jellyfin-apiclient = "v0.7.10"
jellyfin-sdk = "1.6.3"
jellyfin-sdk = "1.6.8"
koin = "4.0.1"
koin-compose = "4.0.1"
kotest = "5.9.1"

View File

@@ -21,6 +21,7 @@ import org.jellyfin.sdk.model.api.PlaybackStartInfo
import org.jellyfin.sdk.model.api.PlaybackStopInfo
import org.jellyfin.sdk.model.api.QueueItem
import org.jellyfin.sdk.model.extensions.inWholeTicks
import timber.log.Timber
import kotlin.math.roundToInt
import org.jellyfin.sdk.model.api.RepeatMode as SdkRepeatMode
@@ -70,6 +71,7 @@ class PlaySessionService(
val stream = entry.mediaStream ?: return
val item = entry.baseItem ?: return
runCatching {
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
itemId = item.id,
@@ -91,6 +93,7 @@ class PlaySessionService(
}
)
)
}.onFailure { error -> Timber.w("Failed to send playback start event", error) }
}
private suspend fun sendStreamUpdate() {
@@ -98,6 +101,7 @@ class PlaySessionService(
val stream = entry.mediaStream ?: return
val item = entry.baseItem ?: return
runCatching {
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = item.id,
@@ -119,6 +123,7 @@ class PlaySessionService(
}
)
)
}.onFailure { error -> Timber.w("Failed to send playback update event", error) }
}
private suspend fun sendStreamStop() {
@@ -126,6 +131,7 @@ class PlaySessionService(
val stream = entry.mediaStream ?: return
val item = entry.baseItem ?: return
runCatching {
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = item.id,
@@ -136,5 +142,6 @@ class PlaySessionService(
nowPlayingQueue = getQueue(),
)
)
}.onFailure { error -> Timber.w("Failed to send playback stop event", error) }
}
}

View File

@@ -5,6 +5,7 @@ import android.content.Context
import android.view.ViewGroup
import androidx.annotation.OptIn
import androidx.core.content.getSystemService
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.PlaybackException
@@ -101,11 +102,13 @@ class ExoPlayerBackend(
setConstantBitrateSeekingAlwaysEnabled(true)
}
))
.setAudioAttributes(AudioAttributes.Builder().apply {
setUsage(C.USAGE_MEDIA)
}.build(), true)
.setPauseAtEndOfMediaItems(true)
.build()
.also { player ->
player.addListener(PlayerListener())
audioPipeline.setAudioSessionId(player.audioSessionId)
if (exoPlayerOptions.enableDebugLogging) {
player.addAnalyticsListener(EventLogger())

View File

@@ -1,6 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<application>
<service
android:name="org.jellyfin.playback.media3.session.AndroidMediaService"
@@ -9,6 +12,7 @@
tools:ignore="ExportedService">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService" />
<action android:name="android.media.browse.MediaBrowserService" />
</intent-filter>
</service>
</application>