Compare commits

...

36 Commits

Author SHA1 Message Date
Niels van Velzen
675ae7c391 Fix retrieveAndPlay incorrectly using ticks instead of milliseconds as start position 2025-07-05 09:32:32 +02:00
dtrexler
8072861522 Clear skip overlay before applying media segments
(cherry picked from commit 46ce0561ae)
2025-07-04 21:09:52 +02:00
Ludovico de Nittis
bb75ad1151 Allow Exoplayer to invalidate the track selection on capabilities change
If the capabilities of a device change, Exoplayer can invalidate the
current selection to adhere to the new list of capabilities.

This helps the Nvidia Shield and its content matching refresh rate
option because, during the refresh rate switching period, the reported
capabilities change. Without this flag, the device could reproduce audio
as PCM instead of the expected passthrough.

Addresses https://github.com/jellyfin/jellyfin-androidtv/issues/4067,
additional context https://github.com/androidx/media/issues/2258

(cherry picked from commit 95f8e4db22)
2025-07-04 21:09:45 +02:00
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
31 changed files with 276 additions and 107 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,14 +42,14 @@ 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);
}
}
} else {
binding.time.setText("");
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

@@ -479,7 +479,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
if (binding.skipOverlay.getVisible()) {
// Hide without doing anything
if (keyCode == KeyEvent.KEYCODE_BACK || keyCode == KeyEvent.KEYCODE_BUTTON_B || keyCode == KeyEvent.KEYCODE_ESCAPE) {
binding.skipOverlay.setTargetPositionMs(null);
clearSkipOverlay();
return true;
}
@@ -487,7 +487,7 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
if (keyCode == KeyEvent.KEYCODE_DPAD_CENTER || keyCode == KeyEvent.KEYCODE_ENTER) {
playbackControllerContainer.getValue().getPlaybackController().seek(binding.skipOverlay.getTargetPositionMs(), true);
leanbackOverlayFragment.setShouldShowOverlay(false);
if (binding != null) binding.skipOverlay.setTargetPositionMs(null);
if (binding != null) clearSkipOverlay();
return true;
}
}
@@ -1263,6 +1263,10 @@ public class CustomPlaybackOverlayFragment extends Fragment implements LiveTvGui
}
}
public void clearSkipOverlay() {
binding.skipOverlay.setTargetPositionMs(null);
}
private void prepareChapterAdapter() {
BaseItemDto item = playbackControllerContainer.getValue().getPlaybackController().getCurrentlyPlayingItem();
List<ChapterInfo> chapters = item.getChapters();
@@ -1281,8 +1285,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")
@@ -151,6 +151,7 @@ fun PlaybackController.applyMediaSegments(
) {
val mediaSegmentRepository by fragment.inject<MediaSegmentRepository>()
fragment?.clearSkipOverlay()
fragment.lifecycleScope.launch {
val mediaSegments = runCatching {
mediaSegmentRepository.getSegmentsForItem(item)

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);
@@ -196,6 +204,7 @@ public class VideoManager {
.setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
.build()
)
.setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
.build()
);
exoPlayerBuilder.setTrackSelector(trackSelector);
@@ -621,6 +630,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

@@ -27,7 +27,6 @@ import org.jellyfin.sdk.model.api.BaseItemKind
import org.jellyfin.sdk.model.api.ItemFields
import org.jellyfin.sdk.model.api.ItemSortBy
import org.jellyfin.sdk.model.api.MediaType
import org.jellyfin.sdk.model.extensions.inWholeTicks
import org.jellyfin.sdk.model.extensions.ticks
import java.util.UUID
import kotlin.time.Duration
@@ -266,7 +265,7 @@ class SdkPlaybackHelper(
navigationRepository.navigate(
playbackLauncher.getPlaybackDestination(
item.type,
pos.inWholeTicks.toInt()
pos.inWholeMilliseconds.toInt()
),
playbackControllerContainer.playbackController?.hasFragment() == 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,27 +71,29 @@ class PlaySessionService(
val stream = entry.mediaStream ?: return
val item = entry.baseItem ?: return
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
itemId = item.id,
playSessionId = stream.identifier,
playlistItemId = item.playlistItemId,
canSeek = true,
isMuted = state.volume.muted,
volumeLevel = (state.volume.volume * 100).roundToInt(),
isPaused = state.playState.value != PlayState.PLAYING,
aspectRatio = state.videoSize.value.aspectRatio.toString(),
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
playMethod = stream.conversionMethod.playMethod,
repeatMode = state.repeatMode.value.remoteRepeatMode,
nowPlayingQueue = getQueue(),
playbackOrder = when (state.playbackOrder.value) {
org.jellyfin.playback.core.model.PlaybackOrder.DEFAULT -> PlaybackOrder.DEFAULT
org.jellyfin.playback.core.model.PlaybackOrder.RANDOM -> PlaybackOrder.SHUFFLE
org.jellyfin.playback.core.model.PlaybackOrder.SHUFFLE -> PlaybackOrder.SHUFFLE
}
runCatching {
api.playStateApi.reportPlaybackStart(
PlaybackStartInfo(
itemId = item.id,
playSessionId = stream.identifier,
playlistItemId = item.playlistItemId,
canSeek = true,
isMuted = state.volume.muted,
volumeLevel = (state.volume.volume * 100).roundToInt(),
isPaused = state.playState.value != PlayState.PLAYING,
aspectRatio = state.videoSize.value.aspectRatio.toString(),
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
playMethod = stream.conversionMethod.playMethod,
repeatMode = state.repeatMode.value.remoteRepeatMode,
nowPlayingQueue = getQueue(),
playbackOrder = when (state.playbackOrder.value) {
org.jellyfin.playback.core.model.PlaybackOrder.DEFAULT -> PlaybackOrder.DEFAULT
org.jellyfin.playback.core.model.PlaybackOrder.RANDOM -> PlaybackOrder.SHUFFLE
org.jellyfin.playback.core.model.PlaybackOrder.SHUFFLE -> PlaybackOrder.SHUFFLE
}
)
)
)
}.onFailure { error -> Timber.w("Failed to send playback start event", error) }
}
private suspend fun sendStreamUpdate() {
@@ -98,27 +101,29 @@ class PlaySessionService(
val stream = entry.mediaStream ?: return
val item = entry.baseItem ?: return
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = item.id,
playSessionId = stream.identifier,
playlistItemId = item.playlistItemId,
canSeek = true,
isMuted = state.volume.muted,
volumeLevel = (state.volume.volume * 100).roundToInt(),
isPaused = state.playState.value != PlayState.PLAYING,
aspectRatio = state.videoSize.value.aspectRatio.toString(),
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
playMethod = stream.conversionMethod.playMethod,
repeatMode = state.repeatMode.value.remoteRepeatMode,
nowPlayingQueue = getQueue(),
playbackOrder = when (state.playbackOrder.value) {
org.jellyfin.playback.core.model.PlaybackOrder.DEFAULT -> PlaybackOrder.DEFAULT
org.jellyfin.playback.core.model.PlaybackOrder.RANDOM -> PlaybackOrder.SHUFFLE
org.jellyfin.playback.core.model.PlaybackOrder.SHUFFLE -> PlaybackOrder.SHUFFLE
}
runCatching {
api.playStateApi.reportPlaybackProgress(
PlaybackProgressInfo(
itemId = item.id,
playSessionId = stream.identifier,
playlistItemId = item.playlistItemId,
canSeek = true,
isMuted = state.volume.muted,
volumeLevel = (state.volume.volume * 100).roundToInt(),
isPaused = state.playState.value != PlayState.PLAYING,
aspectRatio = state.videoSize.value.aspectRatio.toString(),
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
playMethod = stream.conversionMethod.playMethod,
repeatMode = state.repeatMode.value.remoteRepeatMode,
nowPlayingQueue = getQueue(),
playbackOrder = when (state.playbackOrder.value) {
org.jellyfin.playback.core.model.PlaybackOrder.DEFAULT -> PlaybackOrder.DEFAULT
org.jellyfin.playback.core.model.PlaybackOrder.RANDOM -> PlaybackOrder.SHUFFLE
org.jellyfin.playback.core.model.PlaybackOrder.SHUFFLE -> PlaybackOrder.SHUFFLE
}
)
)
)
}.onFailure { error -> Timber.w("Failed to send playback update event", error) }
}
private suspend fun sendStreamStop() {
@@ -126,15 +131,17 @@ class PlaySessionService(
val stream = entry.mediaStream ?: return
val item = entry.baseItem ?: return
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = item.id,
playSessionId = stream.identifier,
playlistItemId = item.playlistItemId,
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
failed = false,
nowPlayingQueue = getQueue(),
runCatching {
api.playStateApi.reportPlaybackStopped(
PlaybackStopInfo(
itemId = item.id,
playSessionId = stream.identifier,
playlistItemId = item.playlistItemId,
positionTicks = withContext(Dispatchers.Main) { state.positionInfo.active.inWholeTicks },
failed = false,
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
@@ -72,6 +73,7 @@ class ExoPlayerBackend(
setAudioOffloadMode(TrackSelectionParameters.AudioOffloadPreferences.AUDIO_OFFLOAD_MODE_ENABLED)
}.build()
)
setAllowInvalidateSelectionsOnRendererCapabilitiesChange(true)
})
})
.setMediaSourceFactory(DefaultMediaSourceFactory(
@@ -101,11 +103,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>