rokkonet

PC・Androidソフトウェア・アプリの開発・使い方に関するメモ

gitコマンド

2026 Jul. 05.
2018 Jun. 20.
2018 Jun. 19.
2018 Jun. 16.
 
gitでは、「リモートのブランチ(GitHubなど)」「ローカルのリモート追跡ブランチ」「ローカルブランチ(作業ブランチ)」のブランチが存在する。

origin とは
リモートリポジトリの名称。
リモートリポジトリには名前が付けられるが、デフォルトでは「origin」というリモート名で設定される。

origin/main(origin/master)とは
リモートリポジトリ「origin」のブランチ「main」(master)を追跡するブランチ。
かつてはmasterであったが今はmainが使われる。

main(master)とは
ブランチの名称。
gitを使用するとデフォルトで作成されるブランチ。
かつてはmasterであったが今はmainが使われる。

ブランチとして、本番環境に反映される安定したコードを管理するmainブランチと開発用のdevelopmentブランチを置くことがあるが、デフォルトで設置されるのはmainブランチのみ。
 
 
ローカルにリポジトリが無い状態でGitHub(BitBucket)を利用する時の作業

#  1. 任意名称のgitプロジェクトディレクトリを作成し移動する
$ mkdir ~/GITPROJECTS
$ cd ~/GITPROJECTS

#  2. GitHubからクローンする
# <URL> はGitHubのリポジトリ画面の「Code」ボタンからコピーしたもの
$ git clone <GitHubのリポジトリURL>

# 3. クローンしたディレクトリに入る
$ cd <リポジトリ名>

 

# 4. 紐付いているか確認
$ git remote -v

# 5. mainブランチに移動
$ git checkout main


# 6. 最新を取得
$ git fetch origin
$ git merge origin/main


# 7. 作業用ブランチを作成して開発開始
$ git checkout -b my-new-feature

 

 

ローカルにmy-new-featureブランチが存在する場合

# 1. ローカルを作業用ブランチに切り替える
$ git checkout my-new-feature

# 2. リモートの最新情報を取得(この時点ではまだマージされない)
$ git fetch origin

# 3. リモートの main をローカルの現在のmy-new-featureブランチにマージする
$ git merge origin/main

 

 

作業が済んだら

$ git add

$ git commi -m "MESSAGE"

$ git push origin my-new-feature


上記の作業に続いて、リモート(GitHub)でのmainブランチへの統合を
GitHubのブラウザ画面で行う。
  • GitHubのリポジトリページを開くと、「my-new-feature had recent pushes...」といったメッセージと共に、黄色の 「Compare & pull request」 というボタンが表示される。それをクリックする。

  • 内容を確認し、「Create pull request」 をクリックする。

  • レビュー担当者が確認してOKが出たら、最後に 「Merge pull request」 ボタンを押す。



"git init" コマンド

リポジトリが初期化され、Gitによるバージョン管理ができるようになる。
ディレクトリ内に 「.git」 というサブディレクトリが作られ、その中にリポジトリに必要なすべてのファイルが格納される。
ローカルリポジトリの削除を行いたい場合は、「.git」ディレクトリを破棄することで削除が可能。

"git add"
ローカルでの更新内容をローカルリポジトリに反映させる際は "git add" コマンドでファイルをインデックスに追加する必要がある。
インデックスとは、コミットしたいファイルを選別して保管しておくための待機スペース。
実際の開発では様々なファイルを同時に編集することが多々あるため、ローカルリポジトリに反映したい内容だけをコミットするために、"git commit"コマンドの前に addコマンドで追加や更新を行うファイルを選別する。

"git commit -m MESSAGE"

ローカルでのファイルの追加や変更内容をローカルリポジトリに反映させるためのコマンド。
Gitを使用してファイルの更新状態を管理する場合に、ファイルの変更内容が自動でローカルリポジトリに反映されることはないため、このコマンドを使用して更新する必要がある。
追加や変更の際には必ず commitコマンドを使用する。
更新内容をローカルリポジトリに反映させる際は、commitコマンドを使用する前に、"git add" コマンドでファイルをインデックスに追加する必要がある。
ファイルの変更内容をcommitコマンドでローカルリポジトリに反映し、その後に pushコマンドでリモートリポジトリにアップロードするという流れ。

"git commit -m MESSAGE“ と -m オプションを使用すると、コミットと同時にコミットメッセージを記述することができる。
オプションを付けずに "git commit" のみで実行した場合は、テキストエディタが開かれる。

"git commit -a" と-aオプションを使用すると、変更された内容全てが自動でインデックスに追加され、そのまま続けてコミットが行われる。

ただし、新規ファイルに関しては -aオプションでコミットすることはできないため、事前にaddコマンドでインデックスに追加しておく必要がある。

"git push origin main"
ローカルのmainブランチの内容を、リモートoriginmainブランチに送信する。


"git fetch origin/master"
リモートのmainブランチの内容が、ローカルのorigin/main(リモート追跡ブランチ)に反映される。
リモートリポジトリの最新情報を、ローカルPCにダウンロードするだけ。
ローカルの自分のmainブランチは、変わらない。

"git checkout main"
ローカルリポジトリにおいてmainブランチに移動する。
originリポジトリを指定しなくても、通常自動的にorigin/mainブランチとなる。

"git fetch origin"
リモートのoriginリポジトリ内容が、ローカルのorigin(リモート追跡リポジトリ)に反映される。
リモートリポジトリの最新情報を、ローカルPCにダウンロードするだけ。
ローカルの自分のリポジトリは、変わらない。

"git merge origin/main"
リモートの変更内容(origin/main)をローカルの現在のブランチ(main)に合体する。

"git reset --soft HEAD^"

で直前のコミットの取り消しが可能。

このコマンドを実行すると、直前のコミットが取り消され、変更したコミット内容がインデックスへと戻される。

pushした後に "git reset" で取り消すと、リモートリポジトリとの履歴にずれが生じて新たに pushすることができなくなってしまう。
コミット内容を既にリモートリポジトリに pushしている場合は、resetコマンドではなく revertコマンドを使用する。

 

 

 
(旧記事)
A-1)リモートリポジトリ作成
A-2)リモートリポジトリのローカルへのclone

B-1)git fetch origin master 
B-2)git merge FETCH_HEAD
 
3)ローカルリポジトリでのファイル作成
4)ローカルリポジトリ内のファイルのadd
  サブディレクトリ作成時はその中にファイルが無ければcommitできない?
      $ git add -A
5)ローカルリポジトリのcommit
      $ git commit -a -m "MESSAGE"
6)ローカルリポジトリ内のリモートリポジトリへのpush
      $ git push origin master


Andoroid開発 : SMBサーバーに接続しメディアファイルをランダム再生

2026 Jun. 07.
2026 May. 04.

Android Studioで、「Andoroid端末をSMBサーバーに接続し、指定したSMBサーバーのディレクトリ内の音声ファイル・動画ファイルをランダムに1つ選んで再生する。SMBサーバーと接続できなかった時は、Andoroid端末の内部共有ストレージにある音声ファイルもしくは動画ファイルをランダムに1つ選んで再生する」アプリ

仕様

  • プロジェクト名は SmbRandomPlay とする。
  • パッケージ名は ne.sy.ro.smbrandomplay とする。
  • 対象Androidバージョンはバージョン11 - バージョン16とする。
  • 画面はjetpack Composeで作成する。
  • アプリの動作中にスマホを90度回転してもアプリが正常に動作するものとする。
  • アプリに配置されたメニューに「smbサーバーアドレス」を置き、smbサーバーのIPアドレスをユーザーがセットするようにする。
  • アプリに配置されたメニューに「smbユーザー」を置き、ユーザーがsmbにアクセスするユーザー名とそのパスワードをセットするようにする。
  • アプリに配置されたメニューに「SMBトップディレクトリ」を置き、それをタップすると、SMBのトップディレクトリ直下のディレクトリの一覧が画面に表示され、表示されているディレクトリの1つをタップするとそのディレクトリの直下にあるディレクトリが表示されるようにし、画面上に配置されたボタンをタップするとその時に指定されているディレクトリが選択されるようにする。また、1つ上のディレクトリに戻る操作もできるようにする。
  • アプリに配置された「smbディレクトリ」メニューをタップすると、その時に指定されているSMBサーバーのディレクトリを起点として、ユーザーがSMBサーバーを指定できるようにする。
  • ユーザーがSMBサーバーのディレクトリを指定したら、そのパスを画面上部に表示する。
  • アプリ画面の下部左側に配置された「Play」ボタンがタップされたら、「Andoroid端末をSMBサーバーに接続し、指定したSMBサーバーのディレクトリ内の音声ファイルもしくは動画ファイルをランダムに1つ選んで再生する。SMBサーバーと接続できなかった時は、Andoroid端末の内部共有ストレージにある音声ファイルもしくは動画ファイルをランダムに1つ選んで再生する」という動作を行う。
  • アプリ画面の下部右側に配置された「Replay」ボタンがタップされたら、直前に「Play」ボタンもしくは「Replay」ボタンで再生されたファイルを再度再生する。
  • 「Play」ボタンと「Replay」ボタンは、タップされた時に何らかの動きをして、タップされたことがユーザーにわかるようにする。
  • 「Play」ボタンでの再生に成功したら、アプリの画面の上部に再生ファイルのパスを表示する。
  • 「Replay」ボタンでの再生に成功したら、アプリの画面の上部に再生ファイルのパスを表示する。
  • SMBサーバーと接続できないときは、端末に元々あるファイル(内部共有ストレージ全体)からランダムに1つの音声ファイルもしくは動画ファイルを再生する。
  • smbへのアクセスではDFS(分散ファイルシステム)を無効化する。
  • smbへのアクセスではSMB署名を緩和するか、可能であればSMB311まで対応するようにする。
  • jcifsを利用する場合は、"jcifs.smb.client.useRawNTLM"をtrueにする設定を入れる。
  • エラー・例外発生時にはその内容をトーストで表示するようにする。
  • SMB上のファイルの再生ではファイルをダウンロードせず、URLのまま外部プレイヤー(VLCなど)に丸投げする。
  • VLC以外のインストールされているプレイヤーも選択できるようにする。

    環境

  • Android Studioのバージョン

Android Studio Narwhal 3 Feature Drop | 2025.1.3
Build #AI-251.26094.121.2513.14007798, built on August 28, 2025
Runtime version: 21.0.7+-13880790-b1038.58 amd64
VM: OpenJDK 64-Bit Server VM by JetBrains s.r.o.
Toolkit: sun.awt.X11.XToolkit
Linux 6.8.0-110-generic
Ubuntu 24.04.4 LTS; glibc: 2.39
Kotlin plugin: K2 mode
GC: G1 Young Generation, G1 Concurrent GC, G1 Old Generation
Memory: 2048M
Cores: 8
Registry:
  ide.experimental.ui=true
Current Desktop: XFCE


Android Studioでの新規プロジェクト作成時

[ Phone and Tablet ] の [ Empty Activity ] を選択する。

作成するファイル

とりあえずsmbサーバーのディレクトリを取得するだけのコード

  • gradle/libs.versions.toml (この内容が最終版)
    gradle/libs.versions.toml を開き、以下の2つの設定がなければ記述する。
[versions]
# バージョン番号を定義(最新版はGoogleの公式ドキュメント等を確認)
androidxLifecycle = "2.8.0" 

[libraries]
# ここにエイリアスを定義
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "androidxLifecycle" }


  • app/build.gradle.kts (最終版ではない)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "ne.sy.ro.smbrandomplay"
    compileSdk = 37

    defaultConfig {
        applicationId = "ne.sy.ro.smbrandomplay"
        minSdk = 30
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = "11"
    }
    buildFeatures {
        compose = true
    }
}

dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.graphics)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.compose.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    debugImplementation(libs.androidx.compose.ui.tooling)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
    // libsフォルダ内のすべてのjarファイルを取り込む設定
    implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
    // ViewModel用
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    // jcifsもここに追加するだけで、ファイル配置不要で導入できる
    implementation("org.codelibs:jcifs:2.1.40")
    implementation("androidx.documentfile:documentfile:1.0.1")
}


  • AndroidManifest.xml (最終版ではない)
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.SmbRandomPlay">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.SmbRandomPlay">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>


  • MainActivity.kt (最終版ではない)
package ne.sy.ro.mylearn01

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface(modifier = Modifier.fillMaxSize()) {
                    SmbBrowserScreen()
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SmbBrowserScreen(viewModel: SmbViewModel = viewModel()) {
    var showMenu by remember { mutableStateOf(false) }
    var showIpDialog by remember { mutableStateOf(false) }
    var showUserDialog by remember { mutableStateOf(false) }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text(text = viewModel.currentPath, style = MaterialTheme.typography.bodySmall) },
                actions = {
                    IconButton(onClick = { showMenu = true }) {
                        Icon(Icons.Default.MoreVert, contentDescription = "メニュー")
                    }
                    DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
                        DropdownMenuItem(
                            text = { Text("smbサーバーアドレス") },
                            onClick = { showMenu = false; showIpDialog = true }
                        )
                        DropdownMenuItem(
                            text = { Text("smbユーザー") },
                            onClick = { showMenu = false; showUserDialog = true }
                        )
                        DropdownMenuItem(
                            text = { Text("smbディレクトリ(更新)") },
                            onClick = { showMenu = false; viewModel.loadDirectory() }
                        )
                    }
                }
            )
        }
    ) { padding ->
        Column(modifier = Modifier.padding(padding)) {
            LazyColumn {
                items(viewModel.directoryList) { dir ->
                    ListItem(
                        headlineContent = { Text(dir) },
                        modifier = Modifier.clickable {
                            viewModel.loadDirectory("${viewModel.currentPath}$dir")
                        }
                    )
                    HorizontalDivider()
                }
            }
        }

        // IPアドレス設定ダイアログ
        if (showIpDialog) {
            SettingDialog(
                title = "サーバー設定",
                value = viewModel.serverIp,
                onConfirm = { viewModel.serverIp = it; showIpDialog = false },
                onDismiss = { showIpDialog = false }
            )
        }

        // ユーザー設定ダイアログ
        if (showUserDialog) {
            UserSettingDialog(
                currentUsername = viewModel.username,
                currentPassword = viewModel.password,
                onConfirm = { u, p -> 
                    viewModel.username = u
                    viewModel.password = p
                    showUserDialog = false 
                },
                onDismiss = { showUserDialog = false }
            )
        }
    }
}

@Composable
fun SettingDialog(title: String, value: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
    var text by remember { mutableStateOf(value) }
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(title) },
        text = { TextField(value = text, onValueChange = { text = it }, label = { Text("IPアドレス") }) },
        confirmButton = { Button(onClick = { onConfirm(text) }) { Text("保存") } }
    )
}

@Composable
fun UserSettingDialog(currentUsername: String, currentPassword: String, onConfirm: (String, String) -> Unit, onDismiss: () -> Unit) {
    var u by remember { mutableStateOf(currentUsername) }
    var p by remember { mutableStateOf(currentPassword) }
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("ユーザー設定") },
        text = {
            Column {
                TextField(value = u, onValueChange = { u = it }, label = { Text("ユーザー名") })
                Spacer(modifier = Modifier.height(8.dp))
                TextField(value = p, onValueChange = { p = it }, label = { Text("パスワード") })
            }
        },
        confirmButton = { Button(onClick = { onConfirm(u, p) }) { Text("保存") } }
    )
}


  • SmbViewModel.kt (データ保持とロジック。最終版ではない)
package ne.sy.ro.mylearn01

import android.app.Application
import android.widget.Toast
import androidx.compose.runtime.*
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import jcifs.context.BaseContext
import jcifs.config.PropertyConfiguration
import jcifs.smb.NtlmPasswordAuthenticator
import jcifs.smb.SmbFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Properties

class SmbViewModel(application: Application) : AndroidViewModel(application) {
    var serverIp by mutableStateOf("")
    var username by mutableStateOf("")
    var password by mutableStateOf("")
    var currentPath by mutableStateOf("smb://")
    var directoryList = mutableStateListOf<String>()

    // JCIFSの基本設定
    private fun getContext(): BaseContext {
        val prop = Properties()
        prop.setProperty("jcifs.smb.client.dfs.disabled", "true")
        prop.setProperty("jcifs.smb.client.useRawNTLM", "true")
        // SMB 3.1.1まで対応させる設定
        prop.setProperty("jcifs.smb.client.minVersion", "SMB1")
        prop.setProperty("jcifs.smb.client.maxVersion", "SMB311")
        
        val config = PropertyConfiguration(prop)
        return BaseContext(config)
    }

    fun loadDirectory(path: String? = null) {
        val targetPath = path ?: "smb://${serverIp}/"
        
        viewModelScope.launch {
            try {
                val list = withContext(Dispatchers.IO) {
                    val auth = NtlmPasswordAuthenticator(null, username, password)
                    val context = getContext().withCredentials(auth)
                    SmbFile(targetPath, context).use { file ->
                        file.listFiles()
                            .filter { it.isDirectory }
                            .map { it.name }
                    }
                }
                directoryList.clear()
                directoryList.addAll(list)
                currentPath = targetPath
            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    Toast.makeText(getApplication(), "エラー: ${e.message}", Toast.LENGTH_LONG).show()
                }
            }
        }
    }
}


ここまでのコードはsmbサーバーのディレクトリを取得するだけ。


ビルド

  • Andoroid Studioのメニュー -> Build -> Assemble 'app' Run Configuration を実行する。


スマホ実機でのrun

  • スマホとAndoroid Studioを通信用USBケーブルでつなぐ。
  • スマホのUSBをファイル転送モードにする。
  • スマホのUSBデバッグを有効にする。
  • Andoroid Studioのメニュー -> Run -> Run 'app' を実行する。


下記のコードは、【フローティング操作ボタン(FAB)で「ディレクトリをここにする!」と決定】と【アプリを閉じて再起動した時」にその場所を開く】の機能を有するコード

  • MainActivity.kt (最終版ではない)
package ne.sy.ro.smbrandomplay

import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.setContent
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface(modifier = Modifier.fillMaxSize()) {
                    SmbBrowserScreen()
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SmbBrowserScreen(viewModel: SmbViewModel = viewModel()) {
    var showMenu by remember { mutableStateOf(false) }
    var showIpDialog by remember { mutableStateOf(false) }
    var showUserDialog by remember { mutableStateOf(false) }
    val context = LocalContext.current

    // --- スマホ本体の戻るボタンの制御 ---
    BackHandler(enabled = viewModel.canGoBack) {
        viewModel.goBack()
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    Text(
                        text = viewModel.currentPath,
                        style = MaterialTheme.typography.bodySmall,
                        maxLines = 1
                    )
                },
                navigationIcon = {
                    if (viewModel.canGoBack) {
                        IconButton(onClick = { viewModel.goBack() }) {
                            Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "戻る")
                        }
                    }
                },
                actions = {
                    IconButton(onClick = { showMenu = true }) {
                        Icon(Icons.Default.MoreVert, contentDescription = "メニュー")
                    }
                    DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
                        DropdownMenuItem(
                            text = { Text("smbサーバーアドレス") },
                            onClick = { showMenu = false; showIpDialog = true }
                        )
                        DropdownMenuItem(
                            text = { Text("smbユーザー") },
                            onClick = { showMenu = false; showUserDialog = true }
                        )
                        DropdownMenuItem(
                            text = { Text("トップへ戻る") },
                            onClick = { showMenu = false; viewModel.goToTop() }
                        )
                        DropdownMenuItem(
                            text = { Text("更新") },
                            onClick = { showMenu = false; viewModel.loadDirectory(viewModel.currentPath) }
                        )
                    }
                }
            )
        },
        // --- フローティング操作ボタン(決定ボタン) ---
        floatingActionButton = {
            // ルート以外(共有フォルダ以下)にいるときだけボタンを表示する場合
            if (viewModel.currentPath != "smb://") {
                ExtendedFloatingActionButton(
                    onClick = {
                        // ここでディレクトリを「決定」した時の処理を行う
                        Toast.makeText(context, "決定: ${viewModel.currentPath}", Toast.LENGTH_SHORT).show()
                    },
                    icon = { Icon(Icons.Default.Check, contentDescription = null) },
                    text = { Text("ここにする!") }
                )
            }
        }
    ) { padding ->
        Column(modifier = Modifier.padding(padding)) {
            LazyColumn {
                items(viewModel.directoryList) { dir ->
                    ListItem(
                        headlineContent = { Text(dir) },
                        modifier = Modifier.clickable {
                            viewModel.loadDirectory("${viewModel.currentPath}$dir")
                        }
                    )
                    HorizontalDivider()
                }
            }
        }

        // ダイアログ類
        if (showIpDialog) {
            SettingDialog(
                title = "サーバー設定",
                value = viewModel.serverIp,
                onConfirm = {
                    viewModel.serverIp = it
                    viewModel.saveSettings()
                    showIpDialog = false
                },
                onDismiss = { showIpDialog = false }
            )
        }

        if (showUserDialog) {
            UserSettingDialog(
                currentUsername = viewModel.username,
                currentPassword = viewModel.password,
                onConfirm = { u, p ->
                    viewModel.username = u
                    viewModel.password = p
                    viewModel.saveSettings()
                    showUserDialog = false
                },
                onDismiss = { showUserDialog = false }
            )
        }
    }
}


@Composable
fun SettingDialog(title: String, value: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
    var text by remember { mutableStateOf(value) }
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(title) },
        text = {
            OutlinedTextField(
                value = text,
                onValueChange = { text = it },
                label = { Text("IPアドレス") },
                singleLine = true
            )
        },
        confirmButton = { TextButton(onClick = { onConfirm(text) }) { Text("保存") } },
        dismissButton = { TextButton(onClick = onDismiss) { Text("キャンセル") } }
    )
}

@Composable
fun UserSettingDialog(currentUsername: String, currentPassword: String, onConfirm: (String, String) -> Unit, onDismiss: () -> Unit) {
    var u by remember { mutableStateOf(currentUsername) }
    var p by remember { mutableStateOf(currentPassword) }
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("ユーザー設定") },
        text = {
            Column {
                OutlinedTextField(value = u, onValueChange = { u = it }, label = { Text("ユーザー名") }, singleLine = true)
                Spacer(modifier = Modifier.height(8.dp))
                OutlinedTextField(value = p, onValueChange = { p = it }, label = { Text("パスワード") }, singleLine = true)
            }
        },
        confirmButton = { TextButton(onClick = { onConfirm(u, p) }) { Text("保存") } },
        dismissButton = { TextButton(onClick = onDismiss) { Text("キャンセル") } }
    )
}


  • SmbViewModel.kt (最終版ではない)
package ne.sy.ro.smbrandomplay

import android.app.Application
import android.content.Context
import android.widget.Toast
import androidx.compose.runtime.*
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import jcifs.context.BaseContext
import jcifs.config.PropertyConfiguration
import jcifs.smb.NtlmPasswordAuthenticator
import jcifs.smb.SmbFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.Properties

class SmbViewModel(application: Application) : AndroidViewModel(application) {
    private val prefs = application.getSharedPreferences("smb_settings", Context.MODE_PRIVATE)

    var serverIp by mutableStateOf(prefs.getString("server_ip", "") ?: "")
    var username by mutableStateOf(prefs.getString("username", "") ?: "")
    var password by mutableStateOf(prefs.getString("password", "") ?: "")

    // 初期値として前回保存したパスを読み込む(なければ smb://)
    var currentPath by mutableStateOf(prefs.getString("last_path", "smb://") ?: "smb://")

    var directoryList = mutableStateListOf<String>()
    private val pathHistory = mutableStateListOf<String>()

    val canGoBack: Boolean get() = pathHistory.isNotEmpty()

    init {
        // 起動時に、もしサーバー設定があれば前回の場所を読みに行く
        if (serverIp.isNotEmpty()) {
            loadDirectory(currentPath)
        }
    }

    private fun getContext(): BaseContext {
        val prop = Properties()

        // --- 安定性のために保持 ---
        prop.setProperty("jcifs.smb.client.dfs.disabled", "true")
        prop.setProperty("jcifs.smb.client.useRawNTLM", "true")

        // 1. セキュリティ署名を強制しない(エラー回避に有効)
        prop.setProperty("jcifs.smb.client.signingPreferred", "false")
        prop.setProperty("jcifs.smb.client.signingEnforced", "false")

        // 2. 拡張セキュリティを有効化(現代のWindows/NASにはほぼ必須)
        prop.setProperty("jcifs.smb.client.useExtendedSecurity", "true")

        // 3. バージョン交渉の範囲を指定
        // 現代のシステムに合わせて、最小をSMB2.0.2、最大をSMB3.1.1に設定
        // サーバー側(Windows 10/11など)でSMB1が完全に無効化されている場合、
        // minVersion を SMB202 以上に設定しないとネゴシエーションに失敗することがある
        prop.setProperty("jcifs.smb.client.minVersion", "SMB202")
        prop.setProperty("jcifs.smb.client.maxVersion", "SMB311")

        return BaseContext(PropertyConfiguration(prop))
    }

    fun saveSettings() {
        prefs.edit().apply {
            putString("server_ip", serverIp)
            putString("username", username)
            putString("password", password)
            putString("last_path", currentPath) // 現在のパスも保存
            apply()
        }
    }

    fun loadDirectory(path: String? = null, isBackNavigation: Boolean = false) {
        val targetPath = path ?: "smb://${serverIp}/"

        viewModelScope.launch {
            try {
                val list = withContext(Dispatchers.IO) {
                    val auth = NtlmPasswordAuthenticator(null, username, password)
                    val context = getContext().withCredentials(auth)
                    SmbFile(targetPath, context).use { file ->
                        file.listFiles()
                            .filter { it.isDirectory }
                            .map { it.name }
                    }
                }

                if (!isBackNavigation && currentPath != targetPath && currentPath != "smb://") {
                    pathHistory.add(currentPath)
                }

                directoryList.clear()
                directoryList.addAll(list)
                currentPath = targetPath

                // 移動に成功したら、その都度パスを保存する
                saveSettings()

            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    Toast.makeText(getApplication(), "エラー: ${e.message}", Toast.LENGTH_LONG).show()
                }
            }
        }
    }

    fun goBack() {
        if (pathHistory.isNotEmpty()) {
            val previousPath = pathHistory.removeAt(pathHistory.size - 1)
            loadDirectory(previousPath, isBackNavigation = true)
        }
    }

    fun goToTop() {
        pathHistory.clear()
        loadDirectory(null, isBackNavigation = true)
    }
}


ここまでで【フローティング操作ボタン(FAB)で「ディレクトリをここにする!」と決定】と【アプリを閉じて再起動した時」にその場所を開く】の機能を有するコードとなった。


下記のコードでsmbサーバー内の音声ファイルもしくは動画ファイルをランダムに1つ選択し、Android端末内に一時ファイルを作成せずに再生することができる。

Aquos sense9、Galaxy A21で動作する。

  • MainActivity.kt (最終版)
package ne.sy.ro.smbrandomplay

import android.content.Intent
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.setContent
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.scale
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.lifecycle.viewmodel.compose.viewModel

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            MaterialTheme {
                Surface(
                    modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colorScheme.background
                ) {
                    SmbBrowserScreen()
                }
            }
        }
    }
}

@Composable
fun AnimatedButton(
    onClick: () -> Unit,
    enabled: Boolean = true,
    content: @Composable RowScope.() -> Unit
) {
    var pressed by remember { mutableStateOf(false) }
    val scale by animateFloatAsState(
        targetValue = if (pressed) 0.88f else 1f,
        animationSpec = tween(durationMillis = 120),
        finishedListener = {
            if (pressed) pressed = false
        },
        label = "buttonScale"
    )

    Button(
        onClick = {
            if (enabled && !pressed) {
                pressed = true
                onClick()
            }
        },
        enabled = enabled,
        modifier = Modifier
            .scale(scale)
            .graphicsLayer {
                alpha = if (pressed) 0.75f else 1f
            },
        content = content
    )
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SmbBrowserScreen(viewModel: SmbViewModel = viewModel()) {
    var showMenu by remember { mutableStateOf(false) }
    var showIpDialog by remember { mutableStateOf(false) }
    var showUserDialog by remember { mutableStateOf(false) }
    val context = LocalContext.current

    val localDirLauncher = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.OpenDocumentTree()
    ) { uri ->
        uri?.let {
            context.contentResolver.takePersistableUriPermission(
                it,
                Intent.FLAG_GRANT_READ_URI_PERMISSION
            )
            viewModel.setLocalDirectory(it)
            Toast.makeText(context, "端末内ディレクトリを設定しました", Toast.LENGTH_SHORT).show()
        }
    }

    // ===== ブラウズモード中はキャンセル、それ以外は従来の戻る動作 =====
    BackHandler(enabled = viewModel.canGoBack || viewModel.selectedPath.isNotEmpty() || viewModel.isBrowsingMode) {
        when {
            viewModel.selectedPath.isNotEmpty() -> viewModel.selectedPath = ""
            viewModel.canGoBack                 -> viewModel.goBack()
            viewModel.isBrowsingMode            -> viewModel.cancelBrowsing()
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = {
                    if (viewModel.selectedPath.isEmpty()) {
                        Text(
                            text = viewModel.currentPath,
                            style = MaterialTheme.typography.bodySmall,
                            maxLines = 1
                        )
                    }
                },
                navigationIcon = {
                    if (viewModel.selectedPath.isEmpty() && viewModel.canGoBack) {
                        IconButton(onClick = { viewModel.goBack() }) {
                            Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "戻る")
                        }
                    }
                },
                actions = {
                    // ===== ブラウズモード中はキャンセルボタンを表示 =====
                    if (viewModel.isBrowsingMode) {
                        IconButton(onClick = { viewModel.cancelBrowsing() }) {
                            Icon(Icons.Default.Close, contentDescription = "キャンセル")
                        }
                    }
                    IconButton(onClick = { showMenu = true }) {
                        Icon(Icons.Default.MoreVert, contentDescription = "メニュー")
                    }
                    DropdownMenu(expanded = showMenu, onDismissRequest = { showMenu = false }) {
                        DropdownMenuItem(
                            text = { Text("smbサーバーアドレス") },
                            onClick = { showMenu = false; showIpDialog = true }
                        )
                        DropdownMenuItem(
                            text = { Text("smbユーザー") },
                            onClick = { showMenu = false; showUserDialog = true }
                        )
                        DropdownMenuItem(
                            text = { Text("端末内のディレクトリ") },
                            onClick = { showMenu = false; localDirLauncher.launch(null) }
                        )
                        DropdownMenuItem(
                            text = { Text("トップへ戻る") },
                            onClick = { showMenu = false; viewModel.goToTop() }
                        )
                        DropdownMenuItem(
                            text = { Text("更新") },
                            onClick = { showMenu = false; viewModel.refresh() }
                        )
                    }
                }
            )
        },

        bottomBar = {
            BottomAppBar {
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 16.dp),
                    horizontalArrangement = Arrangement.SpaceBetween
                ) {
                    AnimatedButton(
                        onClick = { viewModel.playRandomAudio(context) },
                        enabled = !viewModel.isNavigating &&
                                !viewModel.isBrowsingMode &&
                                (viewModel.selectedPath.isNotEmpty() ||
                                        viewModel.localDirectoryUri.isNotEmpty())
                    ) {
                        Text("Play")
                    }

                    AnimatedButton(
                        onClick = { viewModel.startPlayback(context) },
                        enabled = !viewModel.isNavigating &&
                                !viewModel.isBrowsingMode &&
                                viewModel.lastPlayedPath.isNotEmpty()
                    ) {
                        Text("Replay")
                    }
                }
            }
        },

        floatingActionButton = {
            if (viewModel.selectedPath.isEmpty() &&
                viewModel.currentPath != "smb://" &&
                viewModel.currentPath != "smb://${viewModel.serverIp}/"
            ) {
                ExtendedFloatingActionButton(
                    onClick = {
                        viewModel.selectAndReturn()
                        Toast.makeText(context, "パスを決定しました", Toast.LENGTH_SHORT).show()
                    },
                    icon = { Icon(Icons.Default.Check, contentDescription = null) },
                    text = { Text("ここにする!") }
                )
            }
        }

    ) { padding ->
        LazyColumn(
            modifier = Modifier
                .fillMaxSize()
                .padding(padding)
        ) {
            // ===== 変更: 1番目 → サーバー検索場所(旧「決定されたディレクトリ」) =====
            if (viewModel.selectedPath.isNotEmpty()) {
                item {
                    Card(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(16.dp),
                        colors = CardDefaults.cardColors(
                            containerColor = MaterialTheme.colorScheme.secondaryContainer
                        )
                    ) {
                        Column(modifier = Modifier.padding(16.dp)) {
                            Text(
                                text = "サーバー検索場所:",  // ===== 変更 =====
                                style = MaterialTheme.typography.labelMedium,
                                color = MaterialTheme.colorScheme.primary
                            )
                            Spacer(modifier = Modifier.height(8.dp))
                            SelectionContainer {
                                Text(
                                    text = viewModel.selectedPath,
                                    style = MaterialTheme.typography.bodyLarge
                                )
                            }
                        }
                    }
                }
            }
            // =====================================================================

            // ===== 変更: 2番目 → 端末内検索場所(旧「端末内」) =====
            if (viewModel.localDirectoryUri.isNotEmpty()) {
                item {
                    Surface(
                        color = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.5f),
                        modifier = Modifier.fillMaxWidth()
                    ) {
                        Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)) {
                            Text(
                                text = "📁 端末内検索場所: ${viewModel.localDirectoryDisplayName}",  // ===== 変更 =====
                                style = MaterialTheme.typography.labelSmall,
                                color = MaterialTheme.colorScheme.onTertiaryContainer
                            )
                        }
                    }
                }
            }
            // ============================================================

            // ===== 変更: 3番目 → 再生中ファイル情報 =====
            if (viewModel.playingFileName.isNotEmpty()) {
                item {
                    Surface(
                        color = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f),
                        modifier = Modifier.fillMaxWidth()
                    ) {
                        Column(modifier = Modifier.padding(12.dp)) {
                            Text(
                                text = "🎵 再生中: ${viewModel.playingFileName}",
                                style = MaterialTheme.typography.bodyMedium,
                                color = MaterialTheme.colorScheme.onPrimaryContainer,
                                fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
                            )
                            if (viewModel.playingFilePath.isNotEmpty()) {
                                Spacer(modifier = Modifier.height(4.dp))
                                SelectionContainer {
                                    Text(
                                        text = "場所: ${viewModel.playingFilePath}",
                                        style = MaterialTheme.typography.labelSmall,
                                        color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f),
                                        lineHeight = androidx.compose.ui.unit.TextUnit.Unspecified
                                    )
                                }
                            }
                            Text(
                                text = "サイズ: ${viewModel.playingFileSize}",
                                style = MaterialTheme.typography.bodySmall,
                                color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
                            )
                        }
                    }
                }
            }
            // =============================================

            // ディレクトリ一覧(selectedPathが空のときのみ表示)
            if (viewModel.selectedPath.isEmpty()) {
                items(viewModel.directoryList) { dir ->
                    ListItem(
                        headlineContent = { Text(dir) },
                        modifier = Modifier.clickable {
                            viewModel.loadDirectory("${viewModel.currentPath}$dir")
                        }
                    )
                    HorizontalDivider()
                }
            }
        }

        if (showIpDialog) {
            SettingDialog(
                title = "サーバー設定",
                value = viewModel.serverIp,
                onConfirm = {
                    viewModel.applyServerIp(it)
                    showIpDialog = false
                },
                onDismiss = { showIpDialog = false }
            )
        }

        if (showUserDialog) {
            UserSettingDialog(
                currentUsername = viewModel.username,
                currentPassword = viewModel.password,
                onConfirm = { u, p ->
                    viewModel.username = u
                    viewModel.password = p
                    viewModel.saveSettings()
                    showUserDialog = false
                },
                onDismiss = { showUserDialog = false }
            )
        }
    }
}

@Composable
fun SettingDialog(
    title: String,
    value: String,
    onConfirm: (String) -> Unit,
    onDismiss: () -> Unit
) {
    var text by remember { mutableStateOf(value) }
    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text(title) },
        text = {
            OutlinedTextField(
                value = text,
                onValueChange = { text = it },
                label = { Text("IPアドレス") },
                singleLine = true
            )
        },
        confirmButton = { TextButton(onClick = { onConfirm(text) }) { Text("保存") } },
        dismissButton = { TextButton(onClick = onDismiss) { Text("キャンセル") } }
    )
}

@Composable
fun UserSettingDialog(
    currentUsername: String,
    currentPassword: String,
    onConfirm: (String, String) -> Unit,
    onDismiss: () -> Unit
) {
    var u by remember { mutableStateOf(currentUsername) }
    var p by remember { mutableStateOf(currentPassword) }
    var passwordVisible by remember { mutableStateOf(false) }

    AlertDialog(
        onDismissRequest = onDismiss,
        title = { Text("ユーザー設定") },
        text = {
            Column {
                OutlinedTextField(
                    value = u,
                    onValueChange = { u = it },
                    label = { Text("ユーザー名") },
                    singleLine = true
                )
                Spacer(modifier = Modifier.height(8.dp))
                OutlinedTextField(
                    value = p,
                    onValueChange = { p = it },
                    label = { Text("パスワード") },
                    singleLine = true,
                    visualTransformation = if (passwordVisible)
                        VisualTransformation.None
                    else
                        PasswordVisualTransformation(),
                    keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password),
                    trailingIcon = {
                        IconButton(onClick = { passwordVisible = !passwordVisible }) {
                            Icon(
                                imageVector = if (passwordVisible)
                                    Icons.Default.Visibility
                                else
                                    Icons.Default.VisibilityOff,
                                contentDescription = if (passwordVisible) "パスワードを隠す" else "パスワードを表示"
                            )
                        }
                    }
                )
            }
        },
        confirmButton = { TextButton(onClick = { onConfirm(u, p) }) { Text("保存") } },
        dismissButton = { TextButton(onClick = onDismiss) { Text("キャンセル") } }
    )
}


  • SmbViewModel.kt (最終版)
package ne.sy.ro.smbrandomplay

import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.ConnectivityManager
import android.net.NetworkCapabilities
import android.net.Uri
import android.widget.Toast
import androidx.compose.runtime.*
import androidx.documentfile.provider.DocumentFile
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import jcifs.context.BaseContext
import jcifs.config.PropertyConfiguration
import jcifs.smb.NtlmPasswordAuthenticator
import jcifs.smb.SmbFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import java.util.LinkedList
import java.util.Properties

class SmbViewModel(application: Application) : AndroidViewModel(application) {
    private val prefs = application.getSharedPreferences("smb_settings", Context.MODE_PRIVATE)

    var serverIp        by mutableStateOf(prefs.getString("server_ip",        "") ?: "")
    var username        by mutableStateOf(prefs.getString("username",          "") ?: "")
    var password        by mutableStateOf(prefs.getString("password",          "") ?: "")
    var currentPath     by mutableStateOf(prefs.getString("last_path",   "smb://") ?: "smb://")
    var selectedPath    by mutableStateOf(prefs.getString("selected_path",     "") ?: "")

    var playingFileName by mutableStateOf(prefs.getString("playing_name",      "") ?: "")
    var playingFileSize by mutableStateOf(prefs.getString("playing_size",      "") ?: "")
    var lastPlayedPath  by mutableStateOf(prefs.getString("last_played_path",  "") ?: "")
    var playingFilePath by mutableStateOf(prefs.getString("playing_path",      "") ?: "")

    var localDirectoryUri by mutableStateOf(prefs.getString("local_directory_uri", "") ?: "")

    var isNavigating by mutableStateOf(false)

    // ===== ディレクトリ選択モード中かどうかのフラグ =====
    // 「トップへ戻る」「更新」タップ後から「ここにする!」またはキャンセルまでの間 true
    var isBrowsingMode by mutableStateOf(false)

    // ===== キャンセル用に操作前の状態を保存するフィールド =====
    private var savedSelectedPath = ""
    private var savedCurrentPath  = ""
    private val savedPathHistory  = mutableListOf<String>()

    val localDirectoryDisplayName: String
        get() = if (localDirectoryUri.isEmpty()) ""
        else Uri.decode(localDirectoryUri).substringAfterLast('/').ifEmpty { localDirectoryUri }

    var directoryList = mutableStateListOf<String>()
    private val pathHistory = mutableStateListOf<String>()
    val canGoBack: Boolean get() = pathHistory.isNotEmpty()

    private val audioExtensions = listOf(".mp3", ".wav", ".m4a", ".flac", ".ogg", ".aac")
    private val videoExtensions = listOf(".mp4", ".mkv", ".avi", ".mov", ".wmv", ".3gp", ".webm", ".ts")
    private val smbMediaExtensions   = audioExtensions + videoExtensions
    private val localMediaExtensions = audioExtensions + videoExtensions

    private var searchJob: kotlinx.coroutines.Job? = null

    init {
        if (serverIp.isNotEmpty()) {
            loadDirectory(currentPath)
        }
    }

    fun setLocalDirectory(uri: Uri) {
        localDirectoryUri = uri.toString()
        saveSettings()
    }

    private fun getContext(): BaseContext {
        val prop = Properties().apply {
            setProperty("jcifs.smb.client.dfs.disabled",        "true")
            setProperty("jcifs.smb.client.useRawNTLM",          "true")
            setProperty("jcifs.smb.client.useExtendedSecurity", "true")
            setProperty("jcifs.smb.client.signingPreferred",    "false")
            setProperty("jcifs.smb.client.signingEnforced",     "false")
            setProperty("jcifs.smb.client.minVersion",          "SMB202")
            setProperty("jcifs.smb.client.maxVersion",          "SMB311")
            setProperty("jcifs.smb.client.soTimeout",           "30000")
            setProperty("jcifs.smb.client.connTimeout",         "15000")
            setProperty("jcifs.smb.client.responseTimeout",     "30000")
        }
        return BaseContext(PropertyConfiguration(prop))
    }

    fun saveSettings() {
        prefs.edit().apply {
            putString("server_ip",           serverIp)
            putString("username",            username)
            putString("password",            password)
            putString("last_path",           currentPath)
            putString("selected_path",       selectedPath)
            putString("playing_name",        playingFileName)
            putString("playing_size",        playingFileSize)
            putString("last_played_path",    lastPlayedPath)
            putString("playing_path",        playingFilePath)
            putString("local_directory_uri", localDirectoryUri)
            apply()
        }
    }

    private fun isNetworkAvailable(): Boolean {
        val cm = getApplication<Application>()
            .getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager
        val network = cm.activeNetwork ?: return false
        val caps = cm.getNetworkCapabilities(network) ?: return false
        return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
    }

    fun selectAndReturn() {
        selectedPath = currentPath
        isBrowsingMode = false
        saveSettings()
        pathHistory.clear()
        currentPath = "smb://${serverIp}/"
    }

    fun applyServerIp(newIp: String) {
        serverIp = newIp
        selectedPath = ""
        pathHistory.clear()
        saveSettings()
        loadDirectory("smb://$newIp/")
    }

// ===== pathHistoryをenterBrowsingMode()で完全に組み立て、
//             loadDirectory()にはisBackNavigation=trueを渡して
//             loadDirectory()内でのpathHistory操作を抑制する =====
    private fun enterBrowsingMode(startPath: String) {
        savedSelectedPath = selectedPath
        savedCurrentPath  = currentPath
        savedPathHistory.clear()
        savedPathHistory.addAll(pathHistory)
        isBrowsingMode = true
        selectedPath = ""
        pathHistory.clear()

        val topPath = "smb://${serverIp}/"

        // startPathからトップまでの全親パスをpathHistoryに積む
        // 例: startPath = "smb://192.168.1.1/share/music/"
        //   → pathHistory = ["smb://192.168.1.1/", "smb://192.168.1.1/share/"]
        //   → 左矢印1回目: "smb://192.168.1.1/share/" へ(startPathの1つ上)
        //   → 左矢印2回目: "smb://192.168.1.1/" へ(トップ)
        if (startPath != topPath) {
            val segments = startPath
                .removePrefix(topPath)
                .split("/")
                .filter { it.isNotEmpty() }

            var buildPath = topPath
            // startPath自身は含めず、全祖先パスを順に積む
            for (segment in segments) {
                pathHistory.add(buildPath)
                buildPath += "$segment/"
                // buildPathがstartPathと一致したら終了
                if (buildPath == startPath) break
            }
        }

        // isBackNavigation=true でloadDirectory()内のpathHistory自動追加を抑制
        loadDirectory(startPath, isBackNavigation = true)
    }

    // ===== キャンセル処理 =====
    fun cancelBrowsing() {
        isBrowsingMode = false
        selectedPath = savedSelectedPath
        currentPath  = savedCurrentPath
        pathHistory.clear()
        pathHistory.addAll(savedPathHistory)
        // 保存していたディレクトリ一覧を再表示するため再読み込み
        loadDirectory(currentPath, isBackNavigation = true)
    }

    // ===== enterBrowsingMode() 経由でトップへ =====
    fun goToTop() {
        enterBrowsingMode("smb://${serverIp}/")
    }

    // ===== selectedPath のパスから enterBrowsingMode() =====
    fun refresh() {
        val targetPath = if (selectedPath.isNotEmpty()) selectedPath else currentPath
        enterBrowsingMode(targetPath)
    }

    // ===== 1つ上の階層へ戻る処理を改善 =====
    fun goBack() {
        if (pathHistory.isNotEmpty()) {
            val previousPath = pathHistory.removeAt(pathHistory.size - 1)
            loadDirectory(previousPath, isBackNavigation = true)
        } else if (isBrowsingMode) {
            // pathHistoryが空でブラウズモード中は操作前の状態に戻す(キャンセル相当)
            cancelBrowsing()
        }
    }

    fun playRandomAudio(context: android.content.Context) {
        if (searchJob?.isActive == true) return

        if (!isNetworkAvailable()) {
            Toast.makeText(context, "ネットワーク未接続。端末内から再生します", Toast.LENGTH_SHORT).show()
            playRandomLocalMedia(context)
            return
        }

        if (selectedPath.isEmpty()) {
            playRandomLocalMedia(context)
            return
        }

        searchJob = viewModelScope.launch {
            try {
                val allMediaUrls = withContext(Dispatchers.IO) {
                    val auth = NtlmPasswordAuthenticator(null, username, password)
                    val smbContext = getContext().withCredentials(auth)
                    searchIterative(selectedPath, smbContext)
                }

                if (allMediaUrls.isNotEmpty()) {
                    startPlayback(context, allMediaUrls.random())
                } else {
                    withContext(Dispatchers.Main) {
                        Toast.makeText(context, "SMBにメディアファイルが見つかりません。端末内から再生します", Toast.LENGTH_SHORT).show()
                        playRandomLocalMedia(context)
                    }
                }
            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    Toast.makeText(context, "SMB接続失敗。端末内から再生します", Toast.LENGTH_SHORT).show()
                    playRandomLocalMedia(context)
                }
            }
        }
    }

    private fun searchIterative(
        rootPath: String,
        smbContext: jcifs.CIFSContext
    ): List<String> {
        val resultUrls = mutableListOf<String>()
        val queue = LinkedList<String>()
        queue.add(rootPath)

        while (queue.isNotEmpty()) {
            val currentDirPath = queue.poll() ?: break
            try {
                SmbFile(currentDirPath, smbContext).use { dir ->
                    val children = dir.listFiles() ?: return@use
                    for (file in children) {
                        try {
                            when {
                                file.isDirectory -> queue.add(file.path)
                                smbMediaExtensions.any {
                                    file.name.lowercase().endsWith(it)
                                } -> resultUrls.add(file.url.toString())
                            }
                        } catch (e: Exception) {
                            android.util.Log.e("SMB", "ファイルアクセス失敗: ${file.name}")
                        } finally {
                            try { file.close() } catch (_: Exception) {}
                        }
                    }
                }
            } catch (e: Exception) {
                android.util.Log.e("SMB", "ディレクトリ一覧取得失敗: $currentDirPath")
            }
        }
        return resultUrls
    }

    fun playRandomLocalMedia(context: android.content.Context) {
        if (localDirectoryUri.isEmpty()) {
            Toast.makeText(context, "端末内のディレクトリが設定されていません", Toast.LENGTH_SHORT).show()
            return
        }

        viewModelScope.launch {
            val mediaFiles = withContext(Dispatchers.IO) {
                try {
                    val treeUri = Uri.parse(localDirectoryUri)
                    val root = DocumentFile.fromTreeUri(context, treeUri)
                    val result = mutableListOf<DocumentFile>()
                    if (root != null && root.isDirectory) {
                        searchLocalRecursive(root, result)
                    }
                    result
                } catch (e: Exception) {
                    android.util.Log.e("LocalMedia", "端末内検索エラー: ${e.message}")
                    emptyList()
                }
            }

            if (mediaFiles.isNotEmpty()) {
                val chosen = mediaFiles.random()
                withContext(Dispatchers.Main) {
                    playingFileName = chosen.name ?: "不明なファイル"
                    playingFileSize = formatFileSize(chosen.length())
                    playingFilePath = "(端末内) ${chosen.uri.path ?: localDirectoryUri}"
                    saveSettings()

                    val mimeType = resolveMimeType(chosen.name ?: "")
                    val intent = Intent(Intent.ACTION_VIEW).apply {
                        setDataAndType(chosen.uri, mimeType)
                        addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
                        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                    }
                    try {
                        context.startActivity(intent)
                    } catch (e: Exception) {
                        Toast.makeText(context, "再生できるアプリが見つかりません: ${e.message}", Toast.LENGTH_SHORT).show()
                    }
                }
            } else {
                withContext(Dispatchers.Main) {
                    Toast.makeText(context, "端末内にメディアファイルが見つかりません", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    private fun searchLocalRecursive(dir: DocumentFile, result: MutableList<DocumentFile>) {
        try {
            dir.listFiles().forEach { file ->
                try {
                    if (file.isDirectory) {
                        searchLocalRecursive(file, result)
                    } else {
                        val name = file.name?.lowercase() ?: return@forEach
                        if (localMediaExtensions.any { name.endsWith(it) }) {
                            result.add(file)
                        }
                    }
                } catch (e: Exception) {
                    android.util.Log.e("LocalMedia", "ファイルアクセス失敗: ${file.name}")
                }
            }
        } catch (e: Exception) {
            android.util.Log.e("LocalMedia", "ディレクトリ一覧取得失敗")
        }
    }

    fun startPlayback(context: android.content.Context, rawUrl: String? = null) {
        val targetRawUrl = rawUrl ?: lastPlayedPath
        if (targetRawUrl.isEmpty()) return

        viewModelScope.launch(Dispatchers.IO) {
            try {
                if (rawUrl != null) {
                    try {
                        val auth = NtlmPasswordAuthenticator(null, username, password)
                        val smbContext = getContext().withCredentials(auth)
                        SmbFile(targetRawUrl, smbContext).use { file ->
                            val name = file.name
                            val size = formatFileSize(file.length())
                            val displayPath = targetRawUrl.replaceFirst(Regex("smb://.*@"), "smb://")
                            withContext(Dispatchers.Main) {
                                playingFileName = name
                                playingFileSize = size
                                playingFilePath = displayPath
                                lastPlayedPath  = targetRawUrl
                                saveSettings()
                            }
                        }
                    } catch (e: Exception) {
                        android.util.Log.e("SMB", "ファイル情報取得失敗: ${e.message}")
                        withContext(Dispatchers.Main) {
                            lastPlayedPath = targetRawUrl
                            saveSettings()
                        }
                    }
                }

                val encodedUser = android.net.Uri.encode(username)
                val encodedPass = android.net.Uri.encode(password)
                val authUrl = targetRawUrl.replace("smb://", "smb://$encodedUser:$encodedPass@")
                val mimeType = resolveMimeType(targetRawUrl)

                withContext(Dispatchers.Main) {
                    try {
                        val intent = Intent(Intent.ACTION_VIEW).apply {
                            setDataAndType(android.net.Uri.parse(authUrl), mimeType)
                            setPackage("org.videolan.vlc")
                            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                            addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
                            addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP)
                        }
                        context.startActivity(intent)
                    } catch (e: Exception) {
                        val backupIntent = Intent(Intent.ACTION_VIEW).apply {
                            setDataAndType(android.net.Uri.parse(authUrl), mimeType)
                            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                        }
                        context.startActivity(backupIntent)
                    }
                }
            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    Toast.makeText(context, "再生エラー: ${e.message}", Toast.LENGTH_SHORT).show()
                }
            }
        }
    }

    private fun resolveMimeType(filename: String): String {
        val lower = filename.lowercase()
        return when {
            audioExtensions.any { lower.endsWith(it) } -> "audio/*"
            videoExtensions.any { lower.endsWith(it) } -> "video/*"
            else -> "*/*"
        }
    }

    private fun formatFileSize(size: Long): String {
        if (size <= 0) return "0 B"
        val units = arrayOf("B", "KB", "MB", "GB", "TB")
        val digitGroups = (Math.log10(size.toDouble()) / Math.log10(1024.0)).toInt()
        return String.format("%.1f %s", size / Math.pow(1024.0, digitGroups.toDouble()), units[digitGroups])
    }

    fun loadDirectory(path: String? = null, isBackNavigation: Boolean = false) {
        val targetPath = path ?: "smb://${serverIp}/"
        isNavigating = true
        viewModelScope.launch {
            try {
                val list = withContext(Dispatchers.IO) {
                    val auth = NtlmPasswordAuthenticator(null, username, password)
                    val context = getContext().withCredentials(auth)
                    SmbFile(targetPath, context).use { file ->
                        file.listFiles()
                            ?.filter { it.isDirectory }
                            ?.map { it.name }
                            ?: emptyList()
                    }
                }
                if (!isBackNavigation && currentPath != targetPath && currentPath != "smb://") {
                    pathHistory.add(currentPath)
                }
                directoryList.clear()
                directoryList.addAll(list)
                currentPath = targetPath
                saveSettings()
            } catch (e: Exception) {
                withContext(Dispatchers.Main) {
                    Toast.makeText(getApplication(), "エラー: ${e.message}", Toast.LENGTH_LONG).show()
                }
            } finally {
                isNavigating = false
            }
        }
    }
}


  • AndroidManifest.xml (最終版)
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.SmbRandomPlay">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.SmbRandomPlay">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>
    </application>

</manifest>


  • app/build.gradle.kts (最終版)
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "ne.sy.ro.smbrandomplay"
    compileSdk = 37

    defaultConfig {
        applicationId = "ne.sy.ro.smbrandomplay"
        minSdk = 30
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = "11"
    }
    buildFeatures {
        compose = true
    }
}

dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.graphics)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.compose.material3)
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    debugImplementation(libs.androidx.compose.ui.tooling)
    debugImplementation(libs.androidx.compose.ui.test.manifest)
    // libsフォルダ内のすべてのjarファイルを取り込む設定
    implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar"))))
    // ViewModel用
    implementation(libs.androidx.lifecycle.viewmodel.compose)
    // jcifsもここに追加するだけで、ファイル配置不要で導入できる
    implementation("org.codelibs:jcifs:2.1.40")
    implementation("androidx.documentfile:documentfile:1.0.1")
    implementation("androidx.compose.material:material-icons-extended")
}


上記のコードでsmbサーバー内の音声ファイルもしくは動画ファイルをランダムに1つ選択し、Android端末内に一時ファイルを作成せずに再生することができる。

Androidバージョン16(Aquos sense9)、Androidバージョン11(Galaxy A21)で動作する。


指定したディレクトリ内の画像を指定倍率でX Window画面の指定した場所に指定時間スライドショー表示(連続表示)する

2026 Jun. 06.
2022 Mar. 03.

  • LinuxのXubuntu 24.04で動作
  • bashスクリプト

    スクリプトの条件

  • 指定されてディレクリ内の画像ファイルをランダムに選択し、次から次に連続してデスクトップに表示するスクリプトである。
  • コマンド名は"slideshow-random-on-top.sh"とする。
  • コマンドラインオプションには "-d" "-s" "-l" "-t" "-m"を取る。
  • "-d"オプションで、入力する画像が保存されているディレクトリを受け取る。
  • "-d"オプションが指定されていなければ、コマンドラインオプションの使い方を標準エラー出力に出力してコマンドを終了する。
  • "-s"オプションで、画像を表示する大きさを指定する。
  • "-s"オプションが取りうる値は10以上で100以下とする。
  • "-s"オプションが100の時は、画像の縦も横もスクリーンからはみ出ない最大の大きさで画像を表示する。
  • "-s"オプションが10の時は、"-s"オプションが100の時に対し、画像の辺の長さが10/100となるように画層を表示する。
  • "-s"オプションがNの時は、"-s"オプションが100の時に対し、画像の辺の長さがN/100となるように画層を表示する。
  • "-s"オプションのデフォルト値は30とする。
  • "-l"オプションで、画像を表示する位置を文字列で受け取る。文字列が"nw"なら画面左上に表示する。文字列が"ne"なら画面右上に表示する。文字列が"sw"なら画面左下に表示する。文字列が"se"なら画面右下に表示する。文字列が"ce"なら画面中央に表示する。
  • "-l"オプションのデフォルト値は"nw"とする。
  • "-t"オプションで1つの画像が表示されている時間を秒単位で受け取る。
  • "-t"オプションのデフォルト値は 15 とする。
  • "-m"オプションで表示する画像数を指定する。
  • "-m"オプションが指定されなければ、コマンドがkillされるまで動作し続けるものとする。
  • すべてのオプションを指定したコマンドの例は"slideshow-random-on-top.sh -d DIR -s 50 -l ne -t 60 -m 10"となる。
  • コマンドラインオプションの指定が不正な場合は、コマンドラインオプションの使い方を標準エラー出力に出力してコマンドを終了する。
  • ユーザーに表示される「コマンドラインオプションの使い方」は、次のような内容とする。
usage : slideshow-random-on-top.sh -d DIR -s SIZE(%) -l LOCATION -t SECONDS -m NumOfPics

  SIZE: ratio to screen-resolution.
    Unit of SIZE is %.(Integer in 10-100)
    Display-size of picture is SIZE(%) of screen-resolution.

  LOCATION: location to be displayed in screen
    nw: North West
    ne: North East
    sw: South West
    se: South East
    ce: Center

Slideshow pictures randomly.
The picture window is set to top window in desktop.
  • 表示される画像ウインドウはデスクトップのウインドウの最前面となること。
  • 画像の表示方式は、"-d"オプションで指定されたディレクトリ内(子ディレクトリを含む)の画像ファイルを、"-t"オプションで指定された時間ずつ、次々にスライドショー形式で表示することとする。
  • 表示された画像ウインドウのユーザーによるサイズ変更では、縦横比が変わらず、表示されている画像がウインドウサイズ変更に応じて縮小・拡大されること。

    事前作業

$ sudo apt update
$ sudo apt install feh wmctrl x11-utils imagemagick


bashスクリプト

#!/bin/bash
# slideshow-random-on-top.sh
# ランダムスライドショー(最前面表示)for Xubuntu 24.04
# 依存: feh, identify(imagemagick), wmctrl または xdotool, x11-utils(xdpyinfo)

# -------------------------------------------------------
# 使い方ヘルプ
# -------------------------------------------------------
usage() {
    cat >&2 <<'EOF'
usage : slideshow-random-on-top.sh -d DIR -s SIZE(%) -l LOCATION -t SECONDS -m NumOfPics
  SIZE: ratio to screen-resolution.
    Unit of SIZE is %.(Integer in 10-100)
    Display-size of picture is SIZE(%) of screen-resolution.
  LOCATION: location to be displayed in screen
    nw: North West
    ne: North East
    sw: South West
    se: South East
    ce: Center
Slideshow pictures randomly.
The picture window is set to top window in desktop.
EOF
}

# -------------------------------------------------------
# 依存コマンド確認
# -------------------------------------------------------
check_deps() {
    local missing=()
    for cmd in feh identify xdpyinfo; do
        command -v "$cmd" &>/dev/null || missing+=("$cmd")
    done
    if ! command -v wmctrl &>/dev/null && ! command -v xdotool &>/dev/null; then
        missing+=("wmctrl or xdotool")
    fi
    if [[ ${#missing[@]} -gt 0 ]]; then
        echo "Error: 次のコマンドが見つかりません: ${missing[*]}" >&2
        echo "インストール例: sudo apt install feh imagemagick wmctrl x11-utils" >&2
        exit 1
    fi
}

# -------------------------------------------------------
# デフォルト値
# -------------------------------------------------------
OPT_DIR=""
OPT_SIZE=30
OPT_LOCATION="nw"
OPT_SECONDS=15
OPT_MAXPICS=0   # 0 = 無制限

# -------------------------------------------------------
# オプション解析
# -------------------------------------------------------
while getopts ":d:s:l:t:m:" opt; do
    case "$opt" in
        d) OPT_DIR="$OPTARG" ;;
        s) OPT_SIZE="$OPTARG" ;;
        l) OPT_LOCATION="$OPTARG" ;;
        t) OPT_SECONDS="$OPTARG" ;;
        m) OPT_MAXPICS="$OPTARG" ;;
        :)
            echo "Error: オプション -${OPTARG} には引数が必要です。" >&2
            usage; exit 1 ;;
        \?)
            echo "Error: 不正なオプション -${OPTARG}" >&2
            usage; exit 1 ;;
    esac
done

# -d は必須
if [[ -z "$OPT_DIR" ]]; then
    echo "Error: -d オプションは必須です。" >&2
    usage; exit 1
fi

# -d ディレクトリ存在確認
if [[ ! -d "$OPT_DIR" ]]; then
    echo "Error: ディレクトリが存在しません: $OPT_DIR" >&2
    usage; exit 1
fi

# -s 数値・範囲チェック
if ! [[ "$OPT_SIZE" =~ ^[0-9]+$ ]]; then
    echo "Error: -s の値は整数で指定してください: $OPT_SIZE" >&2
    usage; exit 1
fi
if (( OPT_SIZE < 10 || OPT_SIZE > 100 )); then
    echo "Error: -s の値は 10〜100 の範囲で指定してください: $OPT_SIZE" >&2
    usage; exit 1
fi

# -l 値チェック
case "$OPT_LOCATION" in
    nw|ne|sw|se|ce) ;;
    *)
        echo "Error: -l の値が不正です: $OPT_LOCATION (nw/ne/sw/se/ce)" >&2
        usage; exit 1 ;;
esac

# -t 数値チェック(正の整数)
if ! [[ "$OPT_SECONDS" =~ ^[0-9]+$ ]] || (( OPT_SECONDS <= 0 )); then
    echo "Error: -t の値は正の整数で指定してください: $OPT_SECONDS" >&2
    usage; exit 1
fi

# -m 数値チェック(正の整数)
if ! [[ "$OPT_MAXPICS" =~ ^[0-9]+$ ]]; then
    echo "Error: -m の値は正の整数で指定してください: $OPT_MAXPICS" >&2
    usage; exit 1
fi

# -------------------------------------------------------
# 依存確認
# -------------------------------------------------------
check_deps

# -------------------------------------------------------
# 画像ファイル収集
# -------------------------------------------------------
mapfile -t ALL_IMAGES < <(
    find "$OPT_DIR" -type f \( \
        -iname "*.jpg" -o -iname "*.jpeg" -o \
        -iname "*.png" -o -iname "*.gif"  -o \
        -iname "*.bmp" -o -iname "*.tiff" -o \
        -iname "*.tif" -o -iname "*.webp" -o \
        -iname "*.pnm" -o -iname "*.ppm"  -o \
        -iname "*.pgm" -o -iname "*.pbm" \
    \) | sort
)

if [[ ${#ALL_IMAGES[@]} -eq 0 ]]; then
    echo "Error: 画像ファイルが見つかりません: $OPT_DIR" >&2
    exit 1
fi

# -------------------------------------------------------
# スクリーン解像度取得
# -------------------------------------------------------
SCREEN_INFO=$(xdpyinfo | grep -m1 'dimensions:')
SCREEN_W=$(echo "$SCREEN_INFO" | awk '{print $2}' | cut -dx -f1)
SCREEN_H=$(echo "$SCREEN_INFO" | awk '{print $2}' | cut -dx -f2)

# -s% に対応する表示枠(最大ボックス)
MAX_W=$(( SCREEN_W * OPT_SIZE / 100 ))
MAX_H=$(( SCREEN_H * OPT_SIZE / 100 ))

# -------------------------------------------------------
# 画像1枚分のウィンドウサイズを計算する関数
#   引数: 画像パス
#   出力: "W H" (アスペクト比維持、MAX_W×MAX_H に収まるサイズ)
# -------------------------------------------------------
calc_win_size() {
    local img="$1"
    local geom
    geom=$(identify -format "%w %h" "$img[0]" 2>/dev/null)
    local img_w img_h
    img_w=$(echo "$geom" | awk '{print $1}')
    img_h=$(echo "$geom" | awk '{print $2}')

    # identify 失敗時はデフォルトボックスを返す
    if [[ -z "$img_w" || -z "$img_h" || "$img_w" -eq 0 || "$img_h" -eq 0 ]]; then
        echo "$MAX_W $MAX_H"
        return
    fi

    # アスペクト比を維持しながら MAX_W × MAX_H に収まるサイズを計算
    # scale=0 で整数演算、bc で精度を確保
    local win_w win_h
    win_w=$(echo "scale=0; w=$img_w; h=$img_h; mw=$MAX_W; mh=$MAX_H;
        if (w * mh > h * mw) { mw } else { w * mh / h }" | bc)
    win_h=$(echo "scale=0; w=$img_w; h=$img_h; mw=$MAX_W; mh=$MAX_H;
        if (w * mh > h * mw) { h * mw / w } else { mh }" | bc)

    # 最小サイズ保護(1px以上)
    (( win_w < 1 )) && win_w=1
    (( win_h < 1 )) && win_h=1

    echo "$win_w $win_h"
}

# -------------------------------------------------------
# ウィンドウ位置を計算する関数
#   引数: win_w win_h
#   出力: "X Y"
# -------------------------------------------------------
calc_win_pos() {
    local win_w=$1 win_h=$2
    local x y
    case "$OPT_LOCATION" in
        nw) x=0;                          y=0 ;;
        ne) x=$(( SCREEN_W - win_w ));    y=0 ;;
        sw) x=0;                          y=$(( SCREEN_H - win_h )) ;;
        se) x=$(( SCREEN_W - win_w ));    y=$(( SCREEN_H - win_h )) ;;
        ce) x=$(( (SCREEN_W - win_w)/2 )); y=$(( (SCREEN_H - win_h)/2 )) ;;
    esac
    echo "$x $y"
}

# -------------------------------------------------------
# feh を起動する関数
#   引数: 画像パス
#   副作用: FEH_PID, FEH_WID を更新
# -------------------------------------------------------
FEH_PID=""
FEH_WID=""

launch_feh() {
    local img="$1"

    # サイズ・位置計算
    local size pos win_w win_h win_x win_y
    size=$(calc_win_size "$img")
    win_w=$(echo "$size" | awk '{print $1}')
    win_h=$(echo "$size" | awk '{print $2}')
    pos=$(calc_win_pos "$win_w" "$win_h")
    win_x=$(echo "$pos" | awk '{print $1}')
    win_y=$(echo "$pos" | awk '{print $2}')

    feh \
        --title "feh-slideshow" \
        --scale-down \
        --image-bg black \
        --no-menus \
        --geometry "${win_w}x${win_h}+${win_x}+${win_y}" \
        "$img" &
    FEH_PID=$!

    # ウィンドウID取得 & 最前面設定
    sleep 0.5
    if command -v xdotool &>/dev/null; then
        FEH_WID=$(xdotool search --pid "$FEH_PID" 2>/dev/null | head -1)
        if [[ -n "$FEH_WID" ]]; then
            xdotool windowraise   "$FEH_WID" 2>/dev/null
            xdotool windowactivate "$FEH_WID" 2>/dev/null
        fi
    elif command -v wmctrl &>/dev/null; then
        FEH_WID=$(wmctrl -l | grep -i "feh-slideshow" | awk '{print $1}' | head -1)
        [[ -n "$FEH_WID" ]] && wmctrl -i -r "$FEH_WID" -b add,above 2>/dev/null
    fi
}

# -------------------------------------------------------
# クリーンアップ
# -------------------------------------------------------
cleanup() {
    [[ -n "$FEH_PID" ]] && kill "$FEH_PID" 2>/dev/null
    exit 0
}
trap cleanup INT TERM EXIT

# -------------------------------------------------------
# シャッフル関数(Fisher-Yates)
# -------------------------------------------------------
shuffle_array() {
    local -n arr=$1
    local i j tmp n=${#arr[@]}
    for (( i = n-1; i > 0; i-- )); do
        j=$(( RANDOM % (i+1) ))
        tmp="${arr[$i]}"
        arr[$i]="${arr[$j]}"
        arr[$j]="$tmp"
    done
}

# -------------------------------------------------------
# メインループ
# -------------------------------------------------------
SHUFFLED=("${ALL_IMAGES[@]}")
shuffle_array SHUFFLED
IDX=0
COUNT=0

while true; do
    # インデックスが末尾に達したらシャッフルし直してリセット
    if (( IDX >= ${#SHUFFLED[@]} )); then
        shuffle_array SHUFFLED
        IDX=0
    fi

    IMG="${SHUFFLED[$IDX]}"
    IDX=$(( IDX + 1 ))

    # 前の feh を終了
    if [[ -n "$FEH_PID" ]]; then
        kill "$FEH_PID" 2>/dev/null
        wait "$FEH_PID" 2>/dev/null
        FEH_PID=""
        FEH_WID=""
    fi

    # 新しい画像で feh を起動
    launch_feh "$IMG"
    COUNT=$(( COUNT + 1 ))

    # 指定枚数に達したら表示時間を待って終了
    if (( OPT_MAXPICS > 0 && COUNT >= OPT_MAXPICS )); then
        for (( s=0; s<OPT_SECONDS; s++ )); do
            sleep 1
            kill -0 "$FEH_PID" 2>/dev/null || exit 0
        done
        break
    fi

    # 表示時間待機(1秒刻みで feh の死活監視)
    for (( s=0; s<OPT_SECONDS; s++ )); do
        sleep 1
        kill -0 "$FEH_PID" 2>/dev/null || exit 0
    done
done

exit 0




旧記事

最新ソース保管場所 https://bitbucket.org/arsmus/shell-script-public/src/master/image/slideshow-random-on-top.sh

シェル・スクリプト

#!/bin/bash
# 2022 Jan. 23.
# 2021 Feb. 24.
# 2018 Mar. 31.
# 2018 Jan. 21.
# 2017 Nov. 19.
# Ryuichi Hashimoto.

# Slideshow pictures randomly.
# The picture window is set to top window in desktop.
#   usage : COMMAND DIR SIZE LOCATION SECONDS 


Cmd="${0##*/}"

################
### function ###
################
function usage() {
cat << EOP
usage : "${Cmd}" DIR SIZE LOCATION SECONDS 
  SIZE: ratio to screen-resolution.
    Unit of SIZE is %.(Integer in 0-100)
    Display-size of picture is SIZE(%) of screen-resolution.

  LOCATION: location to be displayed in screen
    nw: North West
    ne: North East
    sw: South West
    se: South East
    ce: Center

Slideshow pictures randomly.
The picture window is set to top window in desktop.
EOP
}

############
### main ###
############

# check command line arguements
if [ $# -ne 4 ]; then
  usage 1>&2
  exit 1
fi

Dir="$1"
Dir="${Dir%/}"
if [ ! -d "${Dir}" ]; then
  echo "${Dir} is not directory." >&2 
  echo "${Cmd} DIR SECONDS" >&2
  exit 1
fi

Size=$2
if [ ${Size} -lt 10 ] || [ ${Size} -gt 100 ] ; then
  echo "Unit of SIZE is %.(Integer in 0-100)" 1>&2
  echo "Size of picture is SIZE(%) of screen-resolution." 1>&2
  echo "" 1>&2
  usage 1>&2
  exit 1
fi

Loc="$3"

Secs=$4
if [ $Secs -lt 2 ]; then
  echo "param-seconds is more than 1." >&2 
  usage 1>&2
  exit 1
fi

# check dependency
which xprop 1> /dev/null 2> /dev/null
ReturnCode=$?
if [ $ReturnCode -ne 0 ]; then
   echo "Please install x11-utils" 1>&2
   exit 1
fi

which display 1> /dev/null 2> /dev/null
ReturnCode=$?
if [ $ReturnCode -ne 0 ]; then
   echo "Please install imagemagick" 1>&2
   exit 1
fi

which wmctrl 1> /dev/null 2> /dev/null
ReturnCode=$?
if [ $ReturnCode -ne 0 ]; then
   echo "Please install wmctrl" 1>&2
   exit 1
fi

# get screen resolution
ScreenX=$(xdpyinfo | grep dimensions  | grep -Po -m 1 "[0-9]+x" | head -n 1)
ScreenX=${ScreenX%x}

ScreenY=$(xdpyinfo | grep dimensions  | grep -Po -m 1 "x[0-9]+" | head -n 1)
ScreenY=${ScreenY#x}


# set display size
DisplayX=$(( ${ScreenX} * ${Size} / 100 ))
DisplayY=$(( ${ScreenY} * ${Size} / 100 ))

# get location of picture in screen
case "${Loc}" in
  nw) # North West
    Geomet="${DisplayX}x${DisplayY}+0+0"
    ;;
  ne) # North East
    OffX=$(( ${ScreenX} - ${DisplayX} )) 
    Geomet="${DisplayX}x${DisplayY}+${OffX}+0"
    ;;
  sw) # South West
    OffY=$(( ${ScreenY} - ${DisplayY} )) 
    Geomet="${DisplayX}x${DisplayY}+0+${OffY}"
    ;;
  se) # South East
    OffX=$(( ${ScreenX} - ${DisplayX} )) 
    OffY=$(( ${ScreenY} - ${DisplayY} )) 
    Geomet="${DisplayX}x${DisplayY}+${OffX}+${OffY}"
    ;;
  ce) # Center
    OffX=$(( ${ScreenX} / 2 - ( ${DisplayX} / 2) )) 
    OffY=$(( ${ScreenY} / 2 - ( ${DisplayY} / 2) )) 
    Geomet="${DisplayX}x${DisplayY}+${OffX}+${OffY}"
    ;;
  *)
    echo "Illegal arguement for location." 1>&2
    echo "" 1>&2
    usage 1>&2
    exit 1
    ;;
esac

# slide show
while :
do
   countFiles=`find -L "${Dir}"/ -print0 -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name '*.gif' -o -name '*.bmp' -o -name '*.PNG' -o -name '*.JPG' -o -name '*.JPEG' -o -name '*.GIF' -o -name '*.BMP' | awk -F $"\0" '{print NF}'`

   numFile="$(( $RANDOM % $countFiles + 1 ))"
     # Minimam numFile is 1. Not 0.

   # get activeWindow in the desktop
   activeWin=`xprop -root _NET_ACTIVE_WINDOW`
   #activeWinId=`echo ${activeWin} | grep -Eo '0x[0-9a-fA-F]+' | grep -Eo '0x[0-9a-fA-F]+'`
   activeWinId=$(echo ${activeWin} | grep -oP "0x[0-9aa-fA-F]+" | head -n 1)

 display -resize "${DisplayX}x${DisplayY}" -geometry "${Geomet}" `find -L "${Dir}"/ -print0 -name '*.png' -o -name '*.jpg' -o -name '*.jpeg' -o -name '*.gif' -o -name '*.bmp' -o -name '*.PNG' -o -name '*.JPG' -o -name '*.JPEG' -o -name '*.GIF' -o -name '*.BMP' | awk -F $"\0" -v NumFile="${numFile}" '{print $NumFile}'` &

   sleep 0.5
   wmctrl -r "ImageMagick" -b add,above
   wmctrl -i -R ${activeWinId}
   sleep $(( $Secs - 1 ))
   Pid=`ps a | grep "display -resize " | grep -v grep | awk '{print $1}'`
   kill -9 $Pid
done
exit 0


端末間のファイルのコピー(OpenSSH、rsync)

2026 May 19.
2026 Mar. 24.
2026 Mar. 16.

(scpコマンドではなく)rsyncコマンドで、sshの鍵ファイルを利用してコピーする。

rsyncは、コピー元とコピー先が同じホスト(ローカル)ではsshが不要だが、異なるホスト間(リモート)でのコピーではsshを要する。

条件

SSHサーバーの/SRC/DIRディレクトリをクライアントの/DSTディレクトリの下にコピーするものとする。

SSHサーバーの/SRC/DIRディレクトリ内に存在しないが、クライアントの/DSTディレクトリ内に存在するファイルはそのまま残す。

クライアントの"/home/USER/.ssh/config"ファイルにサーバー接続設定が記述されている。
この記述がなければ、rsyncコマンドのオプションを適宜追加する。

【サーバー側で】

rsyncコマンドのパスを確認する

$ which rsync
 出力:/usr/bin/rsync

パスワードなしでrsync コマンドを sudo で実行できるようにする。

$ sudo visudo
 最下行に"USER ALL=(ALL) NOPASSWD: /usr/bin/rsync"を記述する。
 (USERの部分は適宜変更すること)

【クライアント側で】

/home/USER/.ssh/config内のSSHサーバー定義の秘密鍵をフルパスで記述する


screen を起動する

$ screen -S my_rsync

ssh-agent を準備する

$ eval $(ssh-agent)
$ ssh-add ~/.ssh/秘密鍵ファイル ## パスフレーズを入力する

rsync を実行する

同名ファイルはすべて上書きする場合
$ sudo -E rsync -ahAXzP -e "ssh -F /home/USER/.ssh/config" --rsync-path="sudo rsync" SSHSERVER:/SRC/DIR /DST/ 1> /MY/DIR/STANDARD-OUT.txt 2> /MY/DIR/STANDARD-ERR.txt

同名ファイルはタイムスタンプが新しい方を残す場合
$ sudo -E rsync -ahAXzPu -e "ssh -F /home/USER/.ssh/config" --rsync-path="sudo rsync" SSHSERVER:/SRC/DIR /DST/ 1> /MY/DIR/STANDARD-OUT.txt 2> /MY/DIR/STANDARD-ERR.txt

rsyncコマンドを実行しているターミナルを閉じてもrsyncコマンドの実行を継続するには

  • 別のターミナルを開き、/MY/DIR/STANDARD-OUT.txt、/MY/DIR/STANDARD-ERR.txtへの書き込み状況を確認する。
  • screenのターミナルから抜ける。
     キーボードで Ctrl + A を押した後、指を離して D を押す。(Dの押し下げはCtrlを押していても押していなくてもよい)
     画面に [detached from xxxx.my_rsync] と出れば成功。
     rsyncコマンドの末尾に"&"を付けず、フォアグラウンドで実行すること。

    コピー終了後の処理

screenから出ていたならscreenに入る。
$ screen -r my_rsync

screenを終了する。
$ exit


オプションについて

sudo -E:環境変数を引き継ぐ。ssh-agent と通信するための変数(SSH_AUTH_SOCK)をrsyncコマンドに渡すことができる。

rsyncオプション
-a (archive): オーナー、グループ、権限、タイムスタンプを可能な限り維持。
-h (human-readable):出力の数字を読みやすい単位(MB, GBなど)で表示。
-A (acls):ACL(アクセス制御リスト)を維持。
-X (xattrs):拡張属性を維持。
-z (compress):転送中にデータを圧縮して帯域を節約。
-P:--partial(中断した転送の再開)と --progress(進捗表示)の合わせ技。
-F /home/USER/.ssh/config:root 権限で動きつつも、設定ファイルだけは USER のものを使うよう指示。
--rsync-path="sudo rsync":SSHサーバー側でも sudo 権限で rsync を動かすよう指示。これにより、USER では読めないファイルもコピー可能になる。SSHサーバーの USER ユーザーが、パスワードなし(あるいはパスワード入力あり)で sudo を実行できる権限を持っている必要がある。

-uオプションなしでの上記オプションでコピー元とコピー先に同名のファイルが存在する場合、rsync は以下のステップで判断と処理を行う

  1. 同一かどうかの判定(クイックチェック)
     デフォルトでは、rsync は以下の2点を確認する。
      ファイルサイズ が同じか?
      最終修正日時 (mtime) が同じか?

  2. 判定結果による動作の違い
     A. サイズも修正日時も「同じ」場合
      データ転送はスキップされる。これにより、ネットワーク帯域と時間を大幅に節約する。
      ただし、-aAX オプションがついているため、属性(所有者、グループ、権限、ACL、拡張属性)のみが異なっている場合は、それらのメタデータだけを コピー先に上書きして一致させる。

  3. サイズまたは修正日時が「異なる」場合
     ファイルを更新対象とみなす。
     デルタ転送 (Delta Transfer): rsync はファイル全体を送り直すのではなく、新旧ファイルの「差分」のみを計算して転送し、コピー先側で既存のファイルをパッチするように更新する。
     最終的に、PCB 側のファイルは PCA 側と全く同じ内容・属性に上書きされる。

Android開発 : 音声・動画ファイルをランダム再生するアプリ

2026 May 13.
2026 May 09.

Android端末の「システムメモリ(本体)」「内部共有ストレージ」「/storage/emulated/0」内の音声ファイル・動画ファイルを再生するアプリのコード。

アプリの仕様

  • Android Studioでコードを作成する。
  • Kotlin言語を利用する。
  • Androidバージョン11からAndroidバージョン16のAndroid端末で稼働するようにする。
  • 起動時のメイン画面の右下部に「検索場所」ボタンを置く。
  • 「検索場所」ボタンがタップされると、「検索場所」ボタンがタップされたことが視覚的にわかるようにする。
  • 初めて「検索場所」ボタンがタップされると、「システムメモリ(本体)」「内部共有ストレージ」「/storage/emulated/0」をトップを起点としたディレクトリ選択画面が表示され、ユーザーが1つのディレクトリを選択できるようにする。
  • 過去にディレクトリが設定されている場合は、「検索場所」ボタンがタップされると、直前に設定されたディレクトリでのディレクトリ一覧が表示されるようにする。
  • ディレクトリを選択する画面では、作業のキャンセルが可能なようにする。
  • ディレクトリを選択する画面では、現在表示されているディレクトリの1つ上のディレクトリ階層に行くことができるようにする。
  • 「検索場所」ボタンでディレクトリが選択されたら、アプリ起動時のメイン画面の上部に選択されたディレクトリのフルパスを表示する。
  • アプリ起動時のメイン画面の左下部に「Play」ボタンを置く。
  • 「Play」ボタンがタップされると、「Play」ボタンがタップされたことが視覚的にわかるようにする。
  • 「Play」ボタンがタップされると、 「検索場所」ボタンのタップで決定されたディレクトリ(子ディレクトリを含む)内の音声ファイル・動画ファイルをランダムに1つ選択し、再生する。
  • アプリ起動時のメイン画面の中央下部に「Replay」ボタンを置く。
  • 「Replay」ボタンがタップされると、「Replay」ボタンがタップされたことが視覚的にわかるようにする。
  • 「Replay」ボタンがタップされると、 このアプリで直前に再生されたファイルの再生を開始する。
  • 音声・動画ファイルの再生にはAndroid端末にインストールされているプレーヤーを利用する。
  • 「Play」ボタン・「Replay」ボタンのタップによって音声・動画の再生が始まると、再生中のファイルのフルパスを、アプリ起動時のメイン画面の中央に表示する。
  • 表示されている再生中ファイルのフルパスはコピー可能にする。
  • 再生中のファイルのフルパス表示は別のファイルの再生が始まるまで維持する。
  • アプリを終了しても「検索場所」ボタンで選択されたディレクトリ・最後に再生されたファイルを保持し、次回のアプリ起動時に直前の設定が再現されるようにする。
  • Android端末Galaxy A21(Androidバージョン11)とAndroid端末Aquos Sense9(Androidバージョン16)で利用できるアプリとする。

    環境

  • Android Studioのバージョン
Android Studio Narwhal 3 Feature Drop | 2025.1.3
Build #AI-251.26094.121.2513.14007798, built on August 28, 2025
Runtime version: 21.0.7+-13880790-b1038.58 amd64
VM: OpenJDK 64-Bit Server VM by JetBrains s.r.o.
Toolkit: sun.awt.X11.XToolkit
Linux 6.8.0-111-generic
Ubuntu 24.04.4 LTS; glibc: 2.39
Kotlin plugin: K2 mode
GC: G1 Young Generation, G1 Concurrent GC, G1 Old Generation
Memory: 2048M
Cores: 8
Registry:
  ide.experimental.ui=true
Current Desktop: XFCE


作成するファイル

  • app/build.gradle.kts
    デフォルトのファイルに implementation("androidx.compose.material:material-icons-extended") を追記した。
plugins {
    alias(libs.plugins.android.application)
    alias(libs.plugins.kotlin.android)
    alias(libs.plugins.kotlin.compose)
}

android {
    namespace = "ne.sy.ro.randomplay"
    compileSdk = 36

    defaultConfig {
        applicationId = "ne.sy.ro.randomplay"
        minSdk = 30
        targetSdk = 36
        versionCode = 1
        versionName = "1.0"

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_11
        targetCompatibility = JavaVersion.VERSION_11
    }
    kotlinOptions {
        jvmTarget = "11"
    }
    buildFeatures {
        compose = true
    }
}

dependencies {

    implementation(libs.androidx.core.ktx)
    implementation(libs.androidx.lifecycle.runtime.ktx)
    implementation(libs.androidx.activity.compose)
    implementation(platform(libs.androidx.compose.bom))
    implementation(libs.androidx.compose.ui)
    implementation(libs.androidx.compose.ui.graphics)
    implementation(libs.androidx.compose.ui.tooling.preview)
    implementation(libs.androidx.compose.material3)
    implementation("androidx.compose.material:material-icons-extended")
    testImplementation(libs.junit)
    androidTestImplementation(libs.androidx.junit)
    androidTestImplementation(libs.androidx.espresso.core)
    androidTestImplementation(platform(libs.androidx.compose.bom))
    androidTestImplementation(libs.androidx.compose.ui.test.junit4)
    debugImplementation(libs.androidx.compose.ui.tooling)
    debugImplementation(libs.androidx.compose.ui.test.manifest)

}


- app/src/main/AndroidManifest.xml

<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">

    <!--
        Android 11 / 12(API 30-32)用。
        maxSdkVersion を付けない → Galaxy A21(Android 11)でも権限欄に表示される。
    -->
    <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />

    <!--
        Android 13以上(API 33+)用。
        Android 11 / 12 端末ではシステムが自動的に無視する。
    -->
    <uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
    <uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />

    <application
        android:allowBackup="true"
        android:dataExtractionRules="@xml/data_extraction_rules"
        android:fullBackupContent="@xml/backup_rules"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round"
        android:supportsRtl="true"
        android:theme="@style/Theme.RandomPlay">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:label="@string/app_name"
            android:theme="@style/Theme.RandomPlay">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />

                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!--
            外部プレーヤーへ FileProvider 経由でURIを渡す場合に備えて定義。
            /storage/emulated/0 は content:// URI ではなく
            ACTION_VIEW + file:// で渡せるため、
            minSdk=30 環境ではこのままで動作する。
        -->
        <provider
            android:name="androidx.core.content.FileProvider"
            android:authorities="${applicationId}.fileprovider"
            android:exported="false"
            android:grantUriPermissions="true">
            <meta-data
                android:name="android.support.FILE_PROVIDER_PATHS"
                android:resource="@xml/file_provider_paths" />
        </provider>
    </application>

</manifest>


- MainActivity.kt

package ne.sy.ro.randomplay

import android.Manifest
import android.content.ClipData
import android.content.ClipboardManager
import android.content.Context
import android.content.pm.PackageManager
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.activity.viewModels
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.tween
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.ContextCompat
import net.sytes.rokkosan.randomplay.ui.theme.RandomPlayTheme

class MainActivity : ComponentActivity() {

    private val viewModel: MainViewModel by viewModels()

    private val permissionLauncher =
        registerForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { results ->
            val allGranted = results.values.all { it }
            if (!allGranted) {
                Toast.makeText(
                    this,
                    "ストレージの読み取り権限を許可してください。\n「設定 → アプリ → RandomPlay → 権限」から設定できます。",
                    Toast.LENGTH_LONG
                ).show()
            }
        }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        requestStoragePermissionsIfNeeded() 

        setContent {
            RandomPlayTheme {
                MainScreen(viewModel = viewModel)
            }
        }
    }

    private fun requestStoragePermissionsIfNeeded() {
        val permissions = buildList {
            when {
                // Android 13以上(API 33+)
                Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
                    add(Manifest.permission.READ_MEDIA_AUDIO)
                    add(Manifest.permission.READ_MEDIA_VIDEO)
                }
                // Android 11 / 12(API 30-32)
                else -> {
                    add(Manifest.permission.READ_EXTERNAL_STORAGE)
                }
            }
        }

        val notGranted = permissions.filter {
            ContextCompat.checkSelfPermission(this, it) != PackageManager.PERMISSION_GRANTED
        }

        if (notGranted.isNotEmpty()) {
            permissionLauncher.launch(notGranted.toTypedArray())
        }
    }
}

// ─────────────────────────────────────────────────────────────────────────────
//  メイン画面
// ─────────────────────────────────────────────────────────────────────────────

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(viewModel: MainViewModel) {
    val context = LocalContext.current

    val selectedDirectory by viewModel.selectedDirectory.collectAsState()
    val currentFilePath   by viewModel.currentFilePath.collectAsState()
    val showPicker        by viewModel.showPicker.collectAsState()
    val playPressed       by viewModel.playPressed.collectAsState()
    val replayPressed     by viewModel.replayPressed.collectAsState()
    val searchPressed     by viewModel.searchPressed.collectAsState()

    if (showPicker) {
        DirectoryPickerDialog(
            initialPath         = selectedDirectory.ifBlank { null },
            onDirectorySelected = { viewModel.onDirectorySelected(it) },
            onDismiss           = { viewModel.onPickerDismissed() }
        )
    }

    Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding)
                .padding(horizontal = 16.dp),
            verticalArrangement = Arrangement.SpaceBetween
        ) {
            DirectoryPathSection(selectedDirectory)

            NowPlayingSection(
                filePath = currentFilePath,
                modifier = Modifier.weight(1f)
            )

            BottomButtonRow(
                playPressed     = playPressed,
                replayPressed   = replayPressed,
                searchPressed   = searchPressed,
                onPlayTap       = { viewModel.onPlayTapped(context) },
                onReplayTap     = { viewModel.onReplayTapped(context) },
                onSearchTap     = { viewModel.onSearchTapped() },
                onSearchRelease = { viewModel.onSearchReleased() }
            )

            Spacer(modifier = Modifier.height(8.dp))
        }
    }
}

@Composable
private fun DirectoryPathSection(path: String) {
    Surface(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 12.dp),
        color = MaterialTheme.colorScheme.surfaceVariant,
        shape = MaterialTheme.shapes.medium,
        tonalElevation = 2.dp
    ) {
        Column(modifier = Modifier.padding(12.dp)) {
            Text(
                text = "検索場所",
                style = MaterialTheme.typography.labelSmall,
                color = MaterialTheme.colorScheme.primary,
                fontWeight = FontWeight.Bold
            )
            Spacer(modifier = Modifier.height(4.dp))
            Text(
                text = path.ifBlank { "(未選択)" },
                style = MaterialTheme.typography.bodyMedium,
                fontFamily = FontFamily.Monospace,
                color = if (path.isBlank())
                    MaterialTheme.colorScheme.onSurfaceVariant
                else
                    MaterialTheme.colorScheme.onSurface,
                maxLines = 3,
                overflow = TextOverflow.Ellipsis
            )
        }
    }
}

@Composable
private fun NowPlayingSection(filePath: String, modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val scrollState = rememberScrollState()

    Box(
        modifier = modifier.fillMaxWidth(),
        contentAlignment = Alignment.Center
    ) {
        Surface(
            modifier = Modifier.fillMaxWidth(),
            color = MaterialTheme.colorScheme.surface,
            shape = MaterialTheme.shapes.medium,
            tonalElevation = 0.dp
        ) {
            Column(
                modifier = Modifier
                    .fillMaxWidth()
                    .padding(8.dp),
                horizontalAlignment = Alignment.CenterHorizontally
            ) {
                Text(
                    text = "再生中",
                    style = MaterialTheme.typography.labelSmall,
                    color = MaterialTheme.colorScheme.primary,
                    fontWeight = FontWeight.Bold
                )
                Spacer(modifier = Modifier.height(8.dp))

                if (filePath.isBlank()) {
                    Text(
                        text = "(未再生)",
                        style = MaterialTheme.typography.bodyMedium,
                        color = MaterialTheme.colorScheme.onSurfaceVariant,
                        textAlign = TextAlign.Center
                    )
                } else {
                    Surface(
                        shape = MaterialTheme.shapes.small,
                        color = MaterialTheme.colorScheme.secondaryContainer,
                        modifier = Modifier
                            .fillMaxWidth()
                            .pointerInput(filePath) {
                                detectTapGestures(
                                    onLongPress = {
                                        val cm = context
                                            .getSystemService(Context.CLIPBOARD_SERVICE)
                                                as ClipboardManager
                                        cm.setPrimaryClip(
                                            ClipData.newPlainText("file_path", filePath)
                                        )
                                        Toast.makeText(context, "パスをコピーしました", Toast.LENGTH_SHORT).show()
                                    }
                                )
                            }
                    ) {
                        Text(
                            text = filePath,
                            modifier = Modifier
                                .padding(10.dp)
                                .verticalScroll(scrollState),
                            style = MaterialTheme.typography.bodySmall,
                            fontFamily = FontFamily.Monospace,
                            color = MaterialTheme.colorScheme.onSecondaryContainer,
                            lineHeight = 18.sp
                        )
                    }
                    Spacer(modifier = Modifier.height(4.dp))
                    Text(
                        text = "長押しでパスをコピー",
                        style = MaterialTheme.typography.labelSmall,
                        color = MaterialTheme.colorScheme.onSurfaceVariant
                    )
                }
            }
        }
    }
}

@Composable
private fun BottomButtonRow(
    playPressed: Boolean,
    replayPressed: Boolean,
    searchPressed: Boolean,
    onPlayTap: () -> Unit,
    onReplayTap: () -> Unit,
    onSearchTap: () -> Unit,
    onSearchRelease: () -> Unit
) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .padding(vertical = 12.dp),
        horizontalArrangement = Arrangement.SpaceEvenly,
        verticalAlignment = Alignment.CenterVertically
    ) {
        PressableButton(text = "▶ Play",     pressed = playPressed,   onClick = onPlayTap)
        PressableButton(text = "↺ Replay",   pressed = replayPressed, onClick = onReplayTap)
        PressableButton(
            text = "📂 検索場所",
            pressed = searchPressed,
            onClick = { onSearchTap(); onSearchRelease() }
        )
    }
}

@Composable
private fun PressableButton(text: String, pressed: Boolean, onClick: () -> Unit) {
    val bgColor by animateColorAsState(
        targetValue = if (pressed)
            MaterialTheme.colorScheme.primary.copy(alpha = 0.7f)
        else
            MaterialTheme.colorScheme.primary,
        animationSpec = tween(durationMillis = 120),
        label = "buttonColor"
    )
    Button(
        onClick = onClick,
        colors = ButtonDefaults.buttonColors(containerColor = bgColor),
        modifier = Modifier.defaultMinSize(minWidth = 96.dp)
    ) {
        Text(text = text, fontSize = 13.sp, maxLines = 1)
    }
}


- MainViewModel.kt

package ne.sy.ro.randomplay

import android.app.Application
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.widget.Toast
import androidx.core.content.FileProvider
import androidx.lifecycle.AndroidViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import java.io.File

class MainViewModel(application: Application) : AndroidViewModel(application) {

    private val prefs = PreferencesManager(application)

    // ── 選択済みディレクトリ ──────────────────────────────────────
    private val _selectedDirectory = MutableStateFlow(prefs.selectedDirectory ?: "")
    val selectedDirectory: StateFlow<String> = _selectedDirectory.asStateFlow()

    // ── 現在再生中ファイルパス ────────────────────────────────────
    private val _currentFilePath = MutableStateFlow(prefs.lastPlayedFile ?: "")
    val currentFilePath: StateFlow<String> = _currentFilePath.asStateFlow()

    // ── ディレクトリピッカー表示フラグ ────────────────────────────
    private val _showPicker = MutableStateFlow(false)
    val showPicker: StateFlow<Boolean> = _showPicker.asStateFlow()

    // ── ボタン押下アニメーション用フラグ ──────────────────────────
    private val _playPressed    = MutableStateFlow(false)
    val playPressed: StateFlow<Boolean> = _playPressed.asStateFlow()

    private val _replayPressed  = MutableStateFlow(false)
    val replayPressed: StateFlow<Boolean> = _replayPressed.asStateFlow()

    private val _searchPressed  = MutableStateFlow(false)
    val searchPressed: StateFlow<Boolean> = _searchPressed.asStateFlow()

    // ─────────────────────────────────────────────────────────────

    /** 「検索場所」ボタンタップ */
    fun onSearchTapped() {
        _searchPressed.value = true
        _showPicker.value = true
    }

    fun onSearchReleased() {
        _searchPressed.value = false
    }

    /** ディレクトリピッカーでディレクトリ確定 */
    fun onDirectorySelected(path: String) {
        _selectedDirectory.value = path
        prefs.selectedDirectory = path
        _showPicker.value = false
    }

    /** ディレクトリピッカーをキャンセル */
    fun onPickerDismissed() {
        _showPicker.value = false
        _searchPressed.value = false
    }

    /** 「Play」ボタンタップ:ランダム再生 */
    fun onPlayTapped(context: Context) {
        _playPressed.value = true
        val dir = _selectedDirectory.value
        if (dir.isBlank()) {
            Toast.makeText(context, "先に「検索場所」でフォルダーを選択してください", Toast.LENGTH_SHORT).show()
            _playPressed.value = false
            return
        }
        // ↓ context を渡す形に変更
        val files = MediaFileScanner.scan(context, dir)
        if (files.isEmpty()) {
            Toast.makeText(context, "選択フォルダーに音声・動画ファイルがありません", Toast.LENGTH_SHORT).show()
            _playPressed.value = false
            return
        }
        val picked = files.random()
        playFile(context, picked)
        _playPressed.value = false
    }

    /** 「Replay」ボタンタップ:直前ファイルを再再生 */
    fun onReplayTapped(context: Context) {
        _replayPressed.value = true
        val path = _currentFilePath.value
        if (path.isBlank()) {
            Toast.makeText(context, "再生履歴がありません", Toast.LENGTH_SHORT).show()
            _replayPressed.value = false
            return
        }
        playFile(context, File(path))
        _replayPressed.value = false
    }

    // ─────────────────────────────────────────────────────────────

    /**
     * 端末にインストールされているプレーヤーで [file] を開く。
     * Android 7.0+ は FileProvider 経由の content:// URI を使用。
     */
    private fun playFile(context: Context, file: File) {
        if (!file.exists()) {
            Toast.makeText(context, "ファイルが見つかりません:\n${file.absolutePath}", Toast.LENGTH_LONG).show()
            return
        }

        _currentFilePath.value = file.absolutePath
        prefs.lastPlayedFile   = file.absolutePath

        val uri: Uri = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            FileProvider.getUriForFile(
                context,
                "${context.packageName}.fileprovider",
                file
            )
        } else {
            Uri.fromFile(file)
        }

        val mimeType = guessMimeType(file)
        val intent = Intent(Intent.ACTION_VIEW).apply {
            setDataAndType(uri, mimeType)
            addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
            addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
        }

        // 対応プレーヤーがなければ chooser を表示
        if (intent.resolveActivity(context.packageManager) != null) {
            context.startActivity(intent)
        } else {
            val chooser = Intent.createChooser(intent, "プレーヤーを選択").apply {
                addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
            }
            context.startActivity(chooser)
        }
    }

    private fun guessMimeType(file: File): String {
        return when (file.extension.lowercase()) {
            "mp3"  -> "audio/mpeg"
            "aac"  -> "audio/aac"
            "m4a"  -> "audio/mp4"
            "ogg", "oga" -> "audio/ogg"
            "opus" -> "audio/opus"
            "flac" -> "audio/flac"
            "wav"  -> "audio/wav"
            "wma"  -> "audio/x-ms-wma"
            "mid", "midi" -> "audio/midi"
            "mp4", "m4v" -> "video/mp4"
            "mkv"  -> "video/x-matroska"
            "webm" -> "video/webm"
            "avi"  -> "video/x-msvideo"
            "mov"  -> "video/quicktime"
            "wmv"  -> "video/x-ms-wmv"
            "flv"  -> "video/x-flv"
            "ts"   -> "video/mp2t"
            "3gp"  -> "video/3gpp"
            "mpeg", "mpg" -> "video/mpeg"
            else   -> "*/*"
        }
    }
}


- DirectoryPickerDialog.kt

package ne.sy.ro.randomplay

import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Folder
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import java.io.File

/** /storage/emulated/0 をルートとするディレクトリ選択ダイアログ */
@Composable
fun DirectoryPickerDialog(
    initialPath: String?,
    onDirectorySelected: (String) -> Unit,
    onDismiss: () -> Unit
) {
    val root = File("/storage/emulated/0")

    // 現在表示中のディレクトリ
    var currentDir by remember {
        mutableStateOf(
            if (initialPath != null) {
                val f = File(initialPath)
                if (f.isDirectory) f else f.parentFile ?: root
            } else root
        )
    }

    // サブディレクトリ一覧(読み取り可能なものだけ)
    val subDirs by remember(currentDir) {
        derivedStateOf {
            currentDir.listFiles()
                ?.filter { it.isDirectory && it.canRead() }
                ?.sortedBy { it.name.lowercase() }
                ?: emptyList()
        }
    }

    Dialog(
        onDismissRequest = onDismiss,
        properties = DialogProperties(usePlatformDefaultWidth = false)
    ) {
        Surface(
            modifier = Modifier
                .fillMaxWidth(0.95f)
                .fillMaxHeight(0.85f),
            shape = MaterialTheme.shapes.large,
            tonalElevation = 6.dp
        ) {
            Column(modifier = Modifier.fillMaxSize()) {

                // ── タイトルバー ──────────────────────────────────
                Surface(color = MaterialTheme.colorScheme.primaryContainer) {
                    Row(
                        modifier = Modifier
                            .fillMaxWidth()
                            .padding(horizontal = 12.dp, vertical = 8.dp),
                        verticalAlignment = Alignment.CenterVertically
                    ) {
                        // 1つ上の階層へ
                        IconButton(
                            onClick = {
                                val parent = currentDir.parentFile
                                if (parent != null && currentDir.absolutePath != root.absolutePath) {
                                    currentDir = parent
                                }
                            },
                            enabled = currentDir.absolutePath != root.absolutePath
                        ) {
                            Icon(
                                imageVector = Icons.AutoMirrored.Filled.ArrowBack,
                                contentDescription = "上の階層へ"
                            )
                        }

                        Spacer(modifier = Modifier.width(4.dp))

                        // 現在のパス表示
                        Text(
                            text = currentDir.absolutePath,
                            style = MaterialTheme.typography.bodySmall,
                            fontWeight = FontWeight.Bold,
                            maxLines = 2,
                            overflow = TextOverflow.Ellipsis,
                            modifier = Modifier.weight(1f)
                        )
                    }
                }

                HorizontalDivider()

                // ── ディレクトリ一覧 ─────────────────────────────
                if (subDirs.isEmpty()) {
                    Box(
                        modifier = Modifier
                            .weight(1f)
                            .fillMaxWidth(),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(
                            text = "サブフォルダーがありません",
                            style = MaterialTheme.typography.bodyMedium,
                            color = MaterialTheme.colorScheme.onSurfaceVariant
                        )
                    }
                } else {
                    LazyColumn(modifier = Modifier.weight(1f)) {
                        items(subDirs) { dir ->
                            ListItem(
                                headlineContent = {
                                    Text(
                                        text = dir.name,
                                        maxLines = 1,
                                        overflow = TextOverflow.Ellipsis
                                    )
                                },
                                leadingContent = {
                                    Icon(
                                        imageVector = Icons.Default.Folder,
                                        contentDescription = null,
                                        tint = MaterialTheme.colorScheme.primary
                                    )
                                },
                                modifier = Modifier.clickable { currentDir = dir }
                            )
                            HorizontalDivider(thickness = 0.5.dp)
                        }
                    }
                }

                HorizontalDivider()

                // ── ボタン行 ─────────────────────────────────────
                Row(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(horizontal = 16.dp, vertical = 12.dp),
                    horizontalArrangement = Arrangement.End
                ) {
                    TextButton(onClick = onDismiss) {
                        Text("キャンセル")
                    }
                    Spacer(modifier = Modifier.width(8.dp))
                    Button(onClick = { onDirectorySelected(currentDir.absolutePath) }) {
                        Text("このフォルダーを選択")
                    }
                }
            }
        }
    }
}


- MediaFileScanner.kt

package ne.sy.ro.randomplay

import android.content.Context
import android.provider.MediaStore
import java.io.File

/**
 * 指定ディレクトリ以下(サブディレクトリ含む)の音声・動画ファイルを収集する。
 *
 * Android 11(API 30)以降は File API による /storage/emulated/0 の直接走査が
 * 制限されるため、MediaStore API を使用してファイルを取得する。
 * MediaStore から取得したパスを選択ディレクトリでフィルタリングする方式。
 */
object MediaFileScanner {

    /**
     * [context] の MediaStore から音声・動画ファイルを全取得し、
     * [rootPath] 以下のファイルのみを返す。
     */
    fun scan(context: Context, rootPath: String): List<File> {
        val results = mutableListOf<File>()
        val normalizedRoot = rootPath.trimEnd('/') + "/"

        results += queryMediaStore(
            context    = context,
            uri        = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
            rootPath   = normalizedRoot
        )
        results += queryMediaStore(
            context    = context,
            uri        = MediaStore.Video.Media.EXTERNAL_CONTENT_URI,
            rootPath   = normalizedRoot
        )

        return results.distinctBy { it.absolutePath }
    }

    private fun queryMediaStore(
        context: Context,
        uri: android.net.Uri,
        rootPath: String
    ): List<File> {
        val files = mutableListOf<File>()
        val projection = arrayOf(MediaStore.MediaColumns.DATA)

        try {
            context.contentResolver.query(
                uri,
                projection,
                null,
                null,
                null
            )?.use { cursor ->
                val dataColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATA)
                while (cursor.moveToNext()) {
                    val path = cursor.getString(dataColumn) ?: continue
                    if (path.startsWith(rootPath)) {
                        val file = File(path)
                        if (file.exists()) {
                            files += file
                        }
                    }
                }
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }

        return files
    }
}


- PreferencesManager.kt

package ne.sy.ro.randomplay

import android.content.Context
import androidx.core.content.edit

/**
 * アプリ設定の永続化。
 * - 選択済みディレクトリパス
 * - 最後に再生したファイルパス
 */
class PreferencesManager(context: Context) {

    private val prefs = context.getSharedPreferences(PREF_NAME, Context.MODE_PRIVATE)

    /** 選択済みディレクトリパス(未設定時は null) */
    var selectedDirectory: String?
        get() = prefs.getString(KEY_DIRECTORY, null)
        set(value) = prefs.edit { putString(KEY_DIRECTORY, value) }

    /** 最後に再生したファイルのフルパス(未設定時は null) */
    var lastPlayedFile: String?
        get() = prefs.getString(KEY_LAST_FILE, null)
        set(value) = prefs.edit { putString(KEY_LAST_FILE, value) }

    companion object {
        private const val PREF_NAME  = "randomplay_prefs"
        private const val KEY_DIRECTORY = "selected_directory"
        private const val KEY_LAST_FILE = "last_played_file"
    }
}


- app/src/main/res/xml/file_provider_paths.xml を作成する
手順:
Android Studio の Project ペインで app/src/main/res を右クリック
New → Android Resource File を選択
下記のように入力する
 File name: file_provider_paths
 Resource type: XML
 Root element: paths

作成されたファイルの内容を以下に差し替える

<?xml version="1.0" encoding="utf-8"?>
<paths>
    <external-path name="external_storage" path="." />
</paths>


AndroidスマホをインターネットゲートウェイにしたLAN環境構築

2026 May 13.
2026 Mar. 29.

目標

Wi-Fi/LANが無い環境でAndroid端末とLinux PCとの間で通信できるようにし、携帯電話回線でインターネットを利用できるようにする。

環境

  • Wi-Fi/LANが無い。
  • Android端末aは携帯電話回線でインターネットに接続されており、Wi-Fiへの接続機能も有している。
  • Android端末bは携帯電話回線につながっていないが、Wi-Fiへの接続機能を有している。
  • Xubuntu 24.04 (Linux)パソコンcは無線LANポートを有し、Wi-Fiに接続できる。
  • xubuntu 24.04のパソコンcはopenSSHサーバーである。
  • xubuntu 24.04のパソコンcはSambaサーバーである。
  • Android端末aはopenSSHクライアント機能を有している。
  • Android端末aはSambaクライアント機能を有している。
  • Android端末bはopenSSHクライアント機能を有している。
  • Android端末bはSambaクライアント機能を有している。

    Android端末a、 Android端末b、Xubuntu 24.04 (Linux)パソコンcの間でのネットワーク構築

    手法A テザリング親機に他端末を子接続する方法(セットするたびにIPアドレス再設定・ファイアウォール再設定を要する)

    ネットワーク構築

    (1)Android端末bでpingコマンドを利用できるように、fingとかターミナルアプリ(Termuxなど)とかをインストールする。

    (2)Android端末aのwi-fiテザリングをオンにする。
    (3)Android端末bをAndroid端末aのテザリングに子接続する。
    (4)Xubuntu 24.04のパソコンcをAndroid端末aのテザリングに子接続する。
    (5)Android端末bのWi-Fi設定画面でテザリング子機としてのIPアドレスを確認する。

    (6)Xubuntu(パソコンc)のテザリング子機としてのIPアドレスを確認する。
        "ip a"コマンド または "hostname -I"コマンドを実行して確認する。

    (7)Android端末bとXubuntu(パソコンc)との間で通信回線がつながっていることを確認する。
       Android端末bでのターミナルアプリで"ping -c 3 IP.OF.XUBUNTU.PC"コマンドを実行する。

    Android端末bが、sambaクライアント及びsshクライアントとして、パソコンcのsambaサーバー・sshサーバーにアクセスできるようにする

    (1)Xubuntu(パソコンc)のファイアウォール設定変更を可能にする。
        $ sudo ufw enable

    (2)Sambaを利用可能にする。
      ・Xubuntu(パソコンc)のSambaポートを他のテザリング子機のIPアドレスに対して開放する。
       テザリングネットワークアドレスが 133.27.128.0/24 とすれば
        $ sudo ufw allow from 133.27.128.0/24 to any app Samba

      ・Xubuntu(パソコンc)の/etc/samba/smb.confファイルを編集してSambaサーバー設定を行う。

[global]
  hosts allow = 127. 133.27.128.
    # 接続を許可するネットワーク(既定のものに追記)
    # "127.":ローカル
    # "133.27.128.":テザリングクライアントのアドレス(テザリングを開始するたびにアドレスは変わるので、これは1例)
 
  interfaces = lo wlx1cbfce7d7d1c 133.27.128.0/24
    # 使用するインターフェースを明示(wlx... は "ip a"コマンドで確認したWi-Fi名)
    # "133.27.128.0/24":テザリングクライアントのアドレス(テザリングを開始するたびにアドレスは変わるので、これは1例)

  bind interfaces only = yes
    # 指定したインターフェース以外ではバインドしない


  ・パソコンcのSambaサーバーを再起動する。
    $ sudo systemctl restart smbd
    $ sudo systemctl restart nmbd
      nmbd がタイムアウトする場合は無視してかまわないが、その時は、クライアントからサーバーへの接続時に、サーバー名ではなく、IPアドレスを直接指定する。

  ・Android端末bのSambaクライアント設定で、接続先サーバーのアドレスをXubuntu PCのテザリングアドレスに設定する。

(3)SSHを利用可能にする。
  ・Xubuntu(パソコンc)のSSHポートをテザリングクライアントのIPアドレスに対して開放する。
   テザリングネットワークアドレスが 133.27.128.0/24、SSHポートが22 とすれば
     $ sudo ufw allow from 133.27.128.0/24 to any port 22 proto tcp

  ・Android端末bのSSHクライアント設定で、接続先サーバーのアドレスをXubuntu PCのテザリングアドレスに設定する。

(4)設定したパソコンcのファイアウォールを有効にする。
     $ sudo ufw reload

手法B PCをWi-Fiホットスポットとし、USBテザリングでインターネットにアクセスする方法(PCのテザリング側ポートのアドレスは毎回変わるが、Wi-Fi側は再設定不要)

概要

データ転送可能なUSBケーブルで、Android端末aをUSBテザリングでパソコンcにつなぐ。(テザリングネットワーク)

Android端末はWi-Fi子機として接続しながら携帯回線を共有することはできないので、Android端末aとパソコンcの間は、Wi-Fiテザリングではなく、USBケーブルテザリングとする。

Xubuntuパソコンcのネットワーク設定でWi-Fiホットスポットを作成しWi-Fi親機になり、そこにAndroid端末bを接続させる。(ホットスポットネットワーク)

Xubuntu PC側で「USBからの入力をWi-Fiへ流す」というブリッジ/NAT設定が必要だが、Xubuntuのホットスポット機能(Shared方式)を使えば、GUIで設定できる。

Android端末aのテザリングをオフにしても、パソコンcを中心としたホットスポットネットワークは維持される。

Android端末aからAndroid端末bへの通信は、テザリングネットワークからホットスポットネットワークへのポートフォワーディングを設定する必要がある。

Android端末bからAndroid端末aへの通信はWi-Fiホットスポットによる転送処理が自動的に行われる。

Android端末aは携帯電話回線で直接インターネットにアクセスできる。

パソコンcからは、テザリングネットワークのAndroid端末aをゲートウェイとして携帯電話回線でインターネットにアクセスできる。

Android端末bは、ホットスポットネットワークからテザリングネットワークへの自動転送を経て、Android端末aの携帯電話回線でインターネットにアクセスできる。

ホットスポットネットワーク構築

XubuntuパソコンcでのWi-Fiホットスポットの作成

Xubuntu 24.04のデスクトップの「設定マネージャー」の「高度なネットワーク設定」をクリックすると「ネットワーク接続」ダイアログが表示される。
「ネットワーク接続」ダイアログの「+」ボタンを押すと種類を選ぶように促される。
種類にはEthernet、InfiniBand、Wi-Fi、モバイルブロードバンドなどから選択するようになっている。その中から「Wi-Fi」を選ぶ。
「作成...」 ボタンを押す。

「Wi-Fi」タブでの設定
  接続名: 任意の名前をつける。(例: PC-Hotspot)

  SSID: 端末bなどがWi-Fi一覧で探すときの名前をつける。(例: MyHotspot)

  モード: 「ホットスポット (Hotspot)」 を選択する。

  バンド: 「Automatic」で問題ないが、2.4GHzの方が障害物に強く安定しやすい。

  デバイス: ドロップダウンから、パソコンcのWi-Fiアダプタを選択する。

「Wi-Fi セキュリティ」タブの設定
  セキュリティ: 「WPA & WPA2 Personal」 を選択する。

  パスワード: パスワードを設定する。

「IPv4 設定」タブの設定
  メソッド: 「他のコンピューターと共有 (Shared to other computers)」 を選択する。
    これによってパソコンcがルーターの役割(DHCPサーバーやNAT)を自動で担うようになり、「USBテザリングから来たインターネットを、Wi-Fiに流す」こととなる。

  IPアドレスを固定したい場合、「アドレス」欄にある 「追加」 ボタンから行う。
   アドレス: 10.42.0.1(USBテザリングのネットワークと重ならない任意のプライベートアドレス)
   ネットマスク: 255.255.255.0
   ゲートウェイ: 空欄でよい


パソコンcのホットスポット機能(IPマスカレード/NAT)により、パソコンcのホットスポットにつながっている端末bや、パソコンc自身が、端末aの携帯電話回線を使ってインターネットにアクセスできるようになる。


テザリングネットワーク構築

Android端末aをゲートウェイにしたインターネットアクセス設定

Android端末aとパソコンcをデータ通信用USBケーブルでつなぎ、Android端末aの「USBテザリング」をオンにする。

毎回スマホのメニューをたどるのが面倒な場合、Androidの「開発者オプション」設定内にある「デフォルトのUSB構成」を「USBテザリング」にしておくと、ケーブルを挿すだけでテザリングとなる。

USBテザリングをオンにすると、Xubuntuはそれを「有線LANアダプタ」として自動認識する。

パソコンcのターミナルで "ip addr" を叩くと、新しく usb0 や en... といった名前のインターフェースが出現し、192.168.xxx.xxx、10.x.x.x、172.x.x.x といったプライベートIPアドレスが割り当てられている。
インターフェースの状態に UP, LOWER_UP があればケーブルを介して電気的にも通信的にもリンクが確立されていることを示している。

全体系の完成
  • パソコンcのWi-Fiを一旦オフにし、再度オンにして作成したホットスポット用プロファイルを反映させる。

  • USBテザリングを有効化したAndroid端末aをパソコンcにつなげばパソコンc側で自動的にインターネットが使える状態になる。

  • 下記コマンドで出力が 1 であれば、パソコンcにおいてUSBテザリングからのパケットがWi-Fiホットスポットに流れるように設定されている。(ホットスポットで「他のコンピューターと共有」を選ぶと自動で1になる)
    $ cat /proc/sys/net/ipv4/ip_forward

  • パソコンcで、パケットの住所を書き換える(NAT)設定の確認
    $ sudo iptables -t nat -L -n -v
    出力の中に MASQUERADE という文字が含まれる行があれば、パソコンcがルーターとして機能している。

  • Xubuntuのデスクトップのパネルにあるネットワークアイコンをクリックする。
    "Available networks"から作成した接続名(例: PC-Hotspot)をクリックして接続(有効化)する。
    USBテザリングネットワークである「有線接続」も接続(有効化)する。

  • Android端末bのWi-Fi設定を開き、パソコンcのホットスポットのSSID(Wi-Fi名)を探しパスワードを入力して接続(有効化)する。

    通信ができず、"sudo ufw disable"を実行すると通信が可能となる場合は次のファイアウォール設定を行う。
    $ sudo ufw route allow in on ホットスポットインターフェース out on USBテザリングインターフェース


    ***************************
Galaxyでの問題解決

Android端末bをiPhoneとしたら正常にパソコンcのホットスポットにつながり、Galaxyにするとパソコンcのホットスポットにつながらない現象を下記のWi-Fiバンド設定・暗号化方式設定で解決した。

Wi-Fiバンドを「2.4GHz」の「チャンネル11以下」に固定する

パソコンcでの操作
​ 設定マネージャー -> 高度なネットワーク設定 -> 「ホットスポット」を選択して編集アイコンをクリックする。
​ 「Wi-Fi」タブを開く。
​   バンド: 2.4 GHz を選択する。
​   チャンネル: Automatic ではなく、1、6、11 のいずれかを明示的に選択する。
​   保存して、ホットスポットを再起動する。

暗号化方式を「WPA2」に限定する(PMFの無効化)

​Xubuntu 24.04(NetworkManager)は、デフォルトでセキュリティを強化しようとする。これが「PMF(保護管理フレーム)」という機能を引き起こし、Samsungの一部の端末では「接続のハンドシェイク」に失敗してタイムアウトすることがある。

Xubuntu 24.04の最新のネットワークマネージャーでは、セキュリティの統合が進んでおり、GUIの選択肢から「WPA & WPA2 Personal」が消えて、より広範囲な「WPA/WPA2/WPA3」にまとめられている場合がある。
​Samsung Galaxy端末が接続できない最大の理由は、この「WPA3」の仕様が含まれていることで、PMF(保護管理フレーム)という機能が強制的にオンになってしまうことにある。

- ​パソコンcでの操作
ホットスポットの設定で「WPA & WPA2 Personal」を選択する。

GUIで「WPA & WPA2 Personal」を選択できない場合は、コマンド(nmcli)を使って、強制的に「WPA2のみ(PMFなし)」に設定を変更する。

nmcliコマンドでの設定
接続名(プロファイル名)を確認する
ターミナルで以下を入力し、作成したホットスポットの設定名を把握する。
$ nmcli connection show

一覧の中から、作成したホットスポットの「NAME」を確認する(例:pc12hotspot)。

 nmcliコマンドの出力例

$ nmcli connection show

NAME             UUID                                  TYPE      DEVICE          
pc12hotspot      f0467c15-e047-4b1b-bcdd-dc2227af5a9c  wifi      wlx1cbfce7d7d1c 
pc12hotspot 1    b3aa8fc1-7123-4622-84d4-853d1c2ca8dd  wifi      --

最終列(DEVICE)が"--"となっているものは使用されていない(古い)プロファイル。

使われていないプロファイルを削除する。
$ sudo nmcli connection delete "pc12hotspot 1"

アクティブなプロファイルの修正(Galaxy対策)

# セキュリティを WPA2 (WPA-PSK) に固定し、PMFを無効化する<br>
$ sudo nmcli connection modify "pc12hotspot" 802-11-wireless-security.key-mgmt wpa-psk
$ sudo nmcli connection modify "pc12hotspot" 802-11-wireless-security.proto rsn
$ sudo nmcli connection modify "pc12hotspot" 802-11-wireless-security.pairwise ccmp
$ sudo nmcli connection modify "pc12hotspot" 802-11-wireless-security.pmf 1

# 設定を反映させるために再起動<br>
$ sudo nmcli connection down "pc12hotspot"<br>
$ sudo nmcli connection up "pc12hotspot"<br>

***************************


Android端末bのIPアドレス固定化

Android端末​端末bのWi-FiホットスポットのIPアドレスを、接続するたびにIPアドレスが変わってしまわないように、固定化する。​

Wi-Fi設定から 接続しているホットスポットの詳細を開き下記を設定する。

MACアドレスタイプ: 「デバイスのMAC」 に変更する(これでパソコンc側が「誰が来たか」を識別できるようになる)。

IP設定: 「静的(スタティック)」 にし、アドレスを 192.168.11.10 などに固定する。

パソコンcのUSBテザリングネットワークのIPアドレスの固定化

​Xubuntuの「高度なネットワーク設定」を開く。
​USBテザリング時に現れる接続(例: en...)の設定を開く。
​「IPv4 設定」 を「手動」にし、以下のように設定する。

​アドレス: 10.165.209.94(先ほど確認できた値をそのまま使うのが安全)
​ネットマスク: 24 (または 255.255.255.0)
​ゲートウェイ: 10.165.209.1 (端末aのアドレス)

端末aのUSBテザリングネットワークのIPアドレスの確認方法
パソコンcから確認する方法

​パソコンcは端末aからインターネットの供給を受けているため、パソコンcが「自分の上流(ゲートウェイ)は誰か」を見れば、それが端末aのアドレス。
​ターミナルで以下のコマンドを実行する。
$ ip route | grep default

結果の見方:
​default via 10.165.209.1 dev en... proto dhcp metric 100
​この via の直後に表示されるIPアドレスが、端末aのアドレス。

Android端末a自身で確認する方法

​設定メニューから:
​「設定」→「デバイス情報(または端末情報)」→「ステータス」を選択。
​「IPアドレス」という項目を確認する。
​※注意: ここには「携帯電話回線(4G/5G)」のIPと「USBテザリング用」のIPが並んで表示されることがある。

​「Wi-Fiテザリング」の設定画面から:
機種によっては、テザリング設定の詳細(SSIDなどの設定画面)の下部に「IPv4アドレス」として表示されていることもある。

Android端末aとAndroid端末bが、sambaクライアント及びsshクライアントとして、パソコンcのsambaサーバー・sshサーバーにアクセスできるようにする

(1)パソコンcのファイアウォール設定変更を可能にする。
    $ sudo ufw enable

(2)Sambaを利用可能にする。
  ・パソコンcのSambaポートをテザリングネットワークとホットスポットネットワークに対して開放する。

# USBテザリング側からのアクセスを許可
$ sudo ufw allow in on enhoge to any app Samba
      # enhoge : USBネットワークインタフェース名

# ホットスポット側からのアクセスを許可
$ sudo ufw allow in on wlhoge to any app Samba
      # wlhoge : テザリングネットワークインタフェース名


  ・パソコンcの/etc/samba/smb.confファイルを編集してSambaサーバー設定を行う。

[global]
  hosts allow = 127. 10.165.209. 192.168.11.
    # 接続を許可するネットワーク(既定のものに追記)
    # "127.":ローカル
    # "10.165.209.":テザリングネットワーク(環境に合わせる)
    # "192.168.11.":ホットスポットネットワーク(環境に合わせる)
 
  interfaces = lo enhoge wlhoge
    # 使用するインターフェースを明示する
    #    lo : ローカル
    #    enhoge : USBテザリングインタフェース
    #    wlhoge : ホットスポットインタフェース

  bind interfaces only = yes
    # 指定したインターフェース以外では待ち受けない


  • パソコンcのSambaサーバーを再起動する。

$ sudo systemctl restart smbd nmbd
nmbd がタイムアウトする場合は無視してかまわないが、その時は、クライアントからサーバーへの接続時に、サーバー名ではなく、IPアドレスを直接指定する。

  • Android端末aのSambaクライアントの設定で、接続先サーバーのアドレスとしてパソコンcのホットスポットアドレスを指定する。

  • Android端末bのSambaクライアントの設定で、接続先サーバーのアドレスとしてパソコンcのホットスポットアドレスを指定する。


    (3)SSHを利用可能にする。

  • パソコンcのSSHポートをテザリングネットワークとホットスポットネットワークに対して開放する。

パソコンcのテザリングネットワークインタフェース名がenx、パソコンcのテザリングネットワークインタフェース名がwlx、SSHサーバー待ち受けポートが22 とすれば
$ sudo ufw allow in on enx to any port 22
$ sudo ufw allow in on wlx to any port 22

  • Android端末aのSSHクライアント設定で、接続先サーバーのアドレスにパソコンcのテザリングIPアドレスを指定する。

  • Android端末bのSSHクライアント設定で、接続先サーバーのアドレスにパソコンcのホットスポットIPアドレスを指定する。


(4)ファイアウォール設定を反映させる。
  $ sudo ufw reload

TV録画システム構築:Xubuntu 24.04 / Linux & PT2 TVチューナー & Docker版Mirakurun & Docker版MySQL & Docker版EPGStation

2026 May 12.
2026 Apr. 02.
2026 Mar. 30.
2025 Nov. 07.
2025 Oct. 10.
2025 Sep. 28.
2025 Aug. 24.
2025 Jul. 21.
2025 May 18.
2025 Feb. 24.
2024 Sep. 15.
2024 May 01.

(手順)

PCにPT2チューナー、B-CAS ICカードリーダ、録画ファイル保存用ストレージを接続する。

この記事では、録画ファイル保存ディレクトリを /var/bigdata/02/video とする。

PT2アンテナ端子配置

  S1 BS (PCケース上蓋側)
  T1 地上
  S2 BS
  T2 地上 (マザーボード側)

ロケール、タイムゾーンを設定する

$ sudo apt update
$ sudo apt install language-pack-ja
$ sudo update-locale LANG=ja_JP.UTF-8
$ sudo timedatectl set-timezone Asia/Tokyo


時刻サーバーに同期させる

# /etc/systemd/timesyncd.conf に次の設定を記述する
NTP=ntp.nict.jp
FallbackNTP=ntp1.jst.mfeed.ad.jp ntp2.jst.mfeed.ad.jp ntp3.jst.mfeed.ad.jp

# サービス再起動
$ sudo systemctl restart systemd-timesyncd

# 状態確認
$ systemctl status systemd-timesyncd


ビルドツール、インストールツールをインストール

$ sudo apt install build-essential git unzip dkms pkg-config automake cmake curl libssl-dev libtool libboost-all-dev yasm


(オプション)swapを無効化する

$ sudo swapoff --all
$ sudo reboot


(オプション)/tmpをtmpfsにする

/etc/fstabに次の行を書き込み、システムを再起動する。
tmpfs /tmp tmpfs defaults,noatime 0 0


(オプション)ログをRAMディスクに書き込むようにする

$ echo "deb http://packages.azlux.fr/debian/ buster main" | sudo tee /etc/apt/sources.list.d/azlux.list
$ wget -qO - https://azlux.fr/repo.gpg.key | sudo apt-key add -
$ sudo apt update
$ sudo apt install -y log2ram

/etc/log2ram.confのSIZEとUSE_RSYNCの設定を次のようにする。
SIZE=256MB
USE_RSYNC=true

システムを再起動する。

$ sudo reboot

稼働確認

$ df -h | grep "log2ram"
log2ram         256M   60M  197M  24% /var/log


チューナー PT2がPCに接続されていることを確認

$ lspci
03:00.0 Multimedia controller: Xilinx Corporation Device 222a (rev 01)


PT2デバイスを確認

PT2をPCに装着してLinuxを起動すると自動的に/dev/dvb/以下にデバイスノードが作成される。

$ ls /dev/dvb/
adapter0  adapter1  adapter2  adapter3

/dev/dvb/adapterN が作成されない時は次のリンク記事にしたがって作成する。

Xubuntu 24.04 : TVチューナーカードPT2のデバイスノード/(dev/dvb/adapterN)が自動作成されない時 - rokkonet

次の記事を参考に、B-CASカードリーダをXubuntuにインストールする

ICカードリーダライタSCR3310-NTTComをxubuntuにインストール - rokkonet


pcscdを停止する

Mirakurunでは pcscdが稼働していてはいけない。

$ sudo systemctl stop pcscd.socket
$ sudo systemctl disable pcscd.socket
$ pcsc_scan
 次の出力があれば成功
  SCardEstablishContext: Service not available.


dvb-tools(TV受信コマンドdvbv5-zap)をインストール

$ sudo apt install dvb-tools


b25デコードツールはmirakurunに含まれているのでインストール不要



Docker、docker-composeをインストール

docker-composeをインストールすると「distutilsファイルがない」とのエラーになるのでdocker-compose-v2をインストールする。
$ sudo apt update && sudo apt install docker.io docker-compose-v2

Dockerがインストールされたことを確認する。
$ docker version

Dockerの自動起動設定
$ sudo systemctl enable docker.service
$ systemctl status docker.service

dockerが稼働していなければ
$ sudo systemctl start docker.service


Docker版のMirakurun、MySQL、EPGStationを運用する任意の一般ユーザーを決める(ここではtvユーザーとする)

tvユーザーをdockerグループ、sudoグループに所属させ、ログインしなおす

・所属グループ追加コマンド例

$ sudo usermod -aG docker,sudo tv


いったんXubuntuからログアウトし、tvユーザーで再度ログインする



tvユーザーのホームディレクトリ内にdockerディレクトリを作成する

$ su - tv
$ mkdir ~/docker


Docker版mirakurun-epgstationをインストールする

GitHub - l3tnun/docker-mirakurun-epgstation: Mirakurun + EPGStation on Docker · GitHub ]のsetup.sh
docker-mirakurun-epgstation/setup.sh at v2 · l3tnun/docker-mirakurun-epgstation · GitHub
のとおりだが、次の変更を行ったので、下記の「docker-compose-v2でのDocker版mirakurun-epgstationのインストール」を実行する。

  • docker-composeコマンドをroot権限で実行しないようにした。
  • docker-compose-v2ではsetup.sh内のdocker-composeコマンドが通らず、"docker compose"にする必要があるので、"curl -sf https://raw.githubusercontent.com/l3tnun/docker-mirakurun-epgstation/v2/setup.sh | sh -s cd docker-mirakurun-epgstation"を実行せず、下記手順を進める。

    docker-compose-v2でのDocker版mirakurun-epgstationのインストール
$ cd ~/docker
$ git clone https://github.com/l3tnun/docker-mirakurun-epgstation.git
$ cd ~/docker/docker-mirakurun-epgstation
$ cp docker-compose-sample.yml docker-compose.yml && \
  cp epgstation/config/enc.js.template epgstation/config/enc.js && \
  cp epgstation/config/config.yml.template epgstation/config/config.yml && \
  cp epgstation/config/operatorLogConfig.sample.yml epgstation/config/operatorLogConfig.yml && \
  cp epgstation/config/epgUpdaterLogConfig.sample.yml epgstation/config/epgUpdaterLogConfig.yml && \
  cp epgstation/config/serviceLogConfig.sample.yml epgstation/config/serviceLogConfig.yml

BS15_2 channelの設定を修正する(dvbv5_channels_isdbs.confを修正する)

Mirakurun/config at master · Chinachu/Mirakurun · GitHub を開く。
"dvbconf-for-isdb"で始まるディレクトリを開く。
"conf"ディレクトリを開く。
dvbv5_channels_isdbs.confファイルの[BS15_2]セクションのSTREAM_IDが18675であれば修正の必要なし。

[BS15_2]
    DELIVERY_SYSTEM = ISDBS
    FREQUENCY = 1318000
    STREAM_ID = 18675

修正作業

Mirakurun/config at master · Chinachu/Mirakurun · GitHub を開く。
"dvbconf-for-isdb @ XXXXXXX"ディレクトリのURLの最後の/(スラッシュ)の後の文字列(dvbconf-for-isdb @ XXXXXXXのXXXXXXXから始まる文字列)をメモする。ここでは"e337342d140f24d8d22e1ab75d72a475192dd6ba"とする。

任意の作業ディレクトリに移る。
$ cd /WORK/DIR/

$ git clone https://github.com/Chinachu/dvbconf-for-isdb.git
$ cd dvbconf-for-isdb
$ git checkout e337342d140f24d8d22e1ab75d72a475192dd6ba

dvbv5_channels_isdbs.confをローカルホストのmirakurunコンテナイメージのソースリポジトリに配置する。
$ cp conf/dvbv5_channels_isdbs.conf ~/docker/docker-mirakurun-epgstation/mirakurun/conf/

~/docker/docker-mirakurun-epgstation/mirakurun/conf/dvbv5_channels_isdbs.confの [BS15_2]のセクションのSTREAM_ID設定を18675にする。

[BS15_2]
    DELIVERY_SYSTEM = ISDBS
    FREQUENCY = 1318000
    STREAM_ID = 18675


mirakurunのDockerfileの作成

Docker版MirakurunのDockerfile - rokkonet にしたがって、"~/docker/docker-mirakurun-epgstation/mirakurun/container-init.sh"と"~/docker/docker-mirakurun-epgstation/mirakurun/my.Dockerfile"を作成する。

MySQLのDockerfileの作成

Dockerfileが読み込むentrypointスクリプト作成

$ mkdir ~/docker/docker-mirakurun-epgstation/mysql


~/docker/docker-mirakurun-epgstation/mysql/entrypoint.sh

#!/bin/bash

# cron をバックグラウンドで起動
# cron
service cron start

# cron のログファイルが存在しない場合は作成
touch /var/log/cron.log

# MySQL をフォアグラウンドで起動
exec docker-entrypoint.sh mysqld

実行属性を付ける。

$ chmod +x ~/docker/docker-mirakurun-epgstation/mysql/entrypoint.sh

~/docker/docker-mirakurun-epgstation/mysql/my.Dockerfile を次の内容で作成

FROM mariadb:10.5

RUN apt-get update && apt-get install -y --no-install-recommends \
        vim \
        less \
        cron \
        locales \
        tzdata \
        procps \
    && echo "ja_JP.UTF-8 UTF-8" > /etc/locale.gen \
    && locale-gen \
    && update-locale LANG=ja_JP.UTF-8 \
    && ln -snf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime \
    && echo Asia/Tokyo > /etc/timezone \
    && dpkg-reconfigure -f noninteractive tzdata \
    && rm -rf /var/lib/apt/lists/* \
    && touch /var/log/cron.log

# スクリプトをコンテナにコピー
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

# ENTRYPOINT を変更
ENTRYPOINT ["/entrypoint.sh"]


EPGStationのDockerfileの作成

~/docker/docker-mirakurun-epgstation/epgstation/entrypoint.sh を作成する。


entrypoint.sh

#!/usr/bin/env bash
set -euo pipefail

# ----------------------------------------
# entrypoint.sh for Docker版EPGStation
# - cron をデーモンとして起動
# - EPGStation をフォアグラウンドで起動
# ----------------------------------------

# ログファイルがなければ作成
: > /var/log/cron.log

# cron デーモンをバックグラウンドで起動
echo ">>> Starting cron daemon..."
# cron
service cron start

# (オプション)cron のログをコンテナ stdout に流す
# tail -F /var/log/cron.log &

# EPGStation の起動
echo ">>> Launching EPGStation..."
exec npm run start

実行属性を付ける
$ chmod +x ~/docker/docker-mirakurun-epgstation/epgstation/entrypoint.sh

~/docker/docker-mirakurun-epgstation/epgstation/my.Dockerfile を次の内容で作成する。
########################################
# 1. Build Stage: FFmpeg をコンパイル
########################################
FROM debian:bookworm-slim AS builder

ARG DEV="make gcc git g++ automake curl wget autoconf build-essential pkg-config \
           texinfo zlib1g-dev"
ARG DEVFFMPEG="yasm libx264-dev libmp3lame-dev libopus-dev libvpx-dev libass-dev \
               libfreetype6-dev libsdl1.2-dev libtheora-dev libtool libva-dev \
               libvdpau-dev libvorbis-dev libx265-dev libnuma-dev libaribb24-dev"

ARG FFMPEGVERSION=7.0.1
ENV FFMPEGVERSION=${FFMPEGVERSION}

RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends \
      $DEV \
      $DEVFFMPEG \
      ca-certificates; \
    mkdir -p /tmp/ffmpeg_sources; \
    cd /tmp/ffmpeg_sources; \
    curl -fsSL "http://ffmpeg.org/releases/ffmpeg-${FFMPEGVERSION}.tar.bz2" \
      | tar -xj --strip-components=1; \
    ./configure \
      --prefix=/usr/local \
      --disable-shared \
      --pkg-config-flags="--static" \
      --enable-gpl --enable-version3 --enable-nonfree \
      --enable-libass --enable-libfreetype \
      --enable-libmp3lame --enable-libopus --enable-libtheora \
      --enable-libvorbis --enable-libvpx \
      --enable-libx264 --enable-libx265 --enable-libaribb24 \
      --disable-debug --disable-doc; \
    make -j"$(nproc)"; \
    make install; \
    apt-get purge -y --auto-remove $DEV $DEVFFMPEG; \
    rm -rf /var/lib/apt/lists/* /tmp/ffmpeg_sources

##################################
# 2. Runtime Stage: 必要最小限の共有ライブラリのみ
##################################
FROM l3tnun/epgstation:master-debian

# ビルド成果物をコピー
COPY --from=builder /usr/local /usr/local

ENV LANG=ja_JP.UTF-8 \
    TZ=Asia/Tokyo \
    DEBIAN_FRONTEND=noninteractive

RUN set -eux; \
    apt-get update; \
    apt-get install -y --no-install-recommends \
      cron locales tzdata procps vim less \
      libasound2 libass9 libvdpau1 \
      libva-drm2 libva-x11-2 \
      libvorbisenc2 libtheora0 \
      libvpx7 libopus0 libmp3lame0 libaribb24-0 \
      libx264-164 libx265-199; \
    echo "/usr/local/lib" > /etc/ld.so.conf.d/local-ffmpeg.conf; \
    ldconfig; \
    echo "ja_JP.UTF-8 UTF-8" > /etc/locale.gen; \
    locale-gen; \
    update-locale LANG=ja_JP.UTF-8; \
    ln -snf /usr/share/zoneinfo/Asia/Tokyo /etc/localtime; \
    echo "Asia/Tokyo" > /etc/timezone; \
    dpkg-reconfigure -f noninteractive tzdata; \
    touch /var/log/cron.log; \
    chmod 0644 /var/log/cron.log; \
    apt-get clean; \
    rm -rf /var/lib/apt/lists/*

COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh

ENTRYPOINT ["/entrypoint.sh"]


ホストのtvユーザーで、

$ mkdir ~/docker/docker-mirakurun-epgstation/epgstation/crontab
$ chmod 755 ~/docker/docker-mirakurun-epgstation/epgstation/crontab


ホストに共有ディレクトリを作成する。

Mirakurun
$ mkdir ~/docker/docker-mirakurun-epgstation/mirakurun/crontab  # コンテナのcron設定を保管する場所
$ chmod 755 ~/docker/docker-mirakurun-epgstation/mirakurun/crontab
$ sudo mkdir /var/bigdata/02/video/mirakurun  # EPGStationを使わずMirakurunの機能だけで録画したファイルの保管場所
$ sudo chmod 777 /var/bigdata/02/video/mirakurun
MySQL
$ mkdir -p ~/docker/docker-mirakurun-epgstation/mysql/crontab # コンテナのcron設定を保管する場所
$ chmod 755 ~/docker/docker-mirakurun-epgstation/mysql/crontab
$ mkdir ~/docker/docker-mirakurun-epgstation/mysql/my.cnf # コンテナのMySQL設定を保管する場所
$ sudo chown 999:999 ~/docker/docker-mirakurun-epgstation/mysql/my.cnf  # mysqlコンテナ内のmysqlユーザーID999に合わせる
$ sudo chmod 700 ~/docker/docker-mirakurun-epgstation/mysql/my.cnf
$ mkdir ~/mysql-share # mysqlコンテナとのデータ共有場所
$ chmod 777 ~/mysql-share
EPGStation
これは上記までに作成済み -> $ mkdir ~/docker/docker-mirakurun-epgstation/epgstation/crontab # コンテナのcron設定を保管する場所
これは上記までに設定済み -> $ chmod 755 ~/docker/docker-mirakurun-epgstation/epgstation/crontab
$ sudo mkdir -p /var/bigdata/02/video/epgstation250803/video # 録画ファイル保管場所
$ sudo chmod 777 /var/bigdata/02/video/epgstation250803/video
$ sudo mkdir /var/bigdata/02/video/epgstation250803/thumbnail # 録画ファイルサムネイル保管場所
$ sudo chmod 777 /var/bigdata/02/video/epgstation250803/thumbnail


docker-compose.ymlの作成


元ファイルのバックアップ

$ cp ~/docker/docker-mirakurun-epgstation/docker-compose.yml ~/docker/docker-mirakurun-epgstation/docker-compose.yml.original


~/docker/docker-mirakurun-epgstation/docker-compose.yml を以下の記述に沿って 修正する


各コンテナ内のディレクトリをホストと共有できるようにdocker-compose.ymlの各サービスのvolumes:セクションを修正する


(書式)
 - ホスト側の(docker-compose.ymlから見た相対)パス:コンテナの絶対パス

mirakurun

mirakurunのvolumes:セクションにMirakurun録画ファイルを保管するディレクトリを設定する。
 - /var/bigdata/02/video/mirakurun:/app-recorded

cron設定保管ディレクトリ設定
 - ./mirakurun/crontab:/var/spool/cron/crontabs

EPGStation

録画ファイルをコンテナ外で保存するように、epgstationのvolumes:セクションを修正する。

録画サムネイルファイル保存用と録画ファイル保存用のホスト側ディレクトリは以下とした。
* コンテナ内のthumbnailに結びつけるホスト側ディレクトリ:/var/bigdata/02/video/epgstation240501/thumbnail
* コンテナ内のrecordedに結びつけるホスト側ディレクトリ:/var/bigdata/02/video/epgstation240501/video
* コンテナ内のcrontabに結びつけるホスト側ディレクトリ:~/docker/docker-mirakurun-epgstation/epgstation/crontab

volume設定
* 52行目あたりの"- ./epgstation/thumbnail:/app/thumbnail"の:(コロン)の前は録画サムネイルファイル保存ディレクトリのパスにする。
* 54行目あたりの"- ./recorded:/app/recorded"の:(コロン)の前は録画ファイル保存ディレクトリのパスにする。

MySQL

mysql:のvolumes:セクションに次を記述する。

mysql:
        volumes:
            - mysql-db:/var/lib/mysql
            - /home/tv-xu2404/mysql-share:/tmp # ホストとのデータ共有
            - ./mysql/crontab:/var/spool/cron/crontabs # ホストとのcron設定の共有
            - ./mysql/my.cnf:/etc/mysql/conf.d # ホストとのmysql設定の共有
docker-compose.yml のvolumesへの記述例
 [mirakurunのvolumes設定]
    volumes:
        - ./mirakurun/conf:/app-config
        - ./mirakurun/data:/app-data
        - /var/bigdata/02/video/mirakurun:/app-recorded
        - ./mirakurun/crontab:/var/spool/cron/crontabs

 [mysqlのvolumes設定]
    volumes:
        - mysql-db:/var/lib/mysql
        - /home/tv-xubuntu2404/mysql-share:/tmp
        - ./mysql/crontab:/var/spool/cron/crontabs
        - ./mysql/my.cnf:/etc/mysql/conf.d

 [epgstationのvolumes設定]
    volumes:
        - ./epgstation/config:/app/config
        - ./epgstation/data:/app/data
        - ./epgstation/logs:/app/logs
        - ./epgstation/crontab:/var/spool/cron/crontabs
        - /var/bigdata/02/video/epgstation250222/video:/app/recorded
        - /var/bigdata/02/video/epgstation250222/thumbnail:/app/thumbnail

MySQLのパスワード等設定

  • mysqlのenvironment:セクションにデータベース名・ユーザー名・パスワードを設定する。
  • MYSQL_ROOT_PASSWORD変数にMySQLのrootユーザーのパスワードを設定する。(デフォルトではepgstationになっている)
  • ユーザー名、データベース名、ユーザーパスワードはepgstationとしておくのが無難。

    各コンテナにタイムゾーン・ロケールを設定する

services:
    mirakurun:
        environment:
            TZ: "Asia/Tokyo"
            LANG: ja_JP.UTF-8
            LC_ALL: ja_JP.UTF-8

    mysql:
        environment:
            TZ: "Asia/Tokyo"
            LANG: ja_JP.UTF-8
            LC_ALL: ja_JP.UTF-8

    epgstation:
        environment:
            TZ: "Asia/Tokyo"
            LANG: ja_JP.UTF-8
            LC_ALL: ja_JP.UTF-8

Mirakurun、MYSQL、EPGStationの各コンテナの確実な連携

epgstation:
  environment:
    TZ: "Asia/Tokyo"
    LANG: ja_JP.UTF-8
    LC_ALL: ja_JP.UTF-8
    HOST: "0.0.0.0"
    PORT: "8888"
    REMOTE_MIRAKURUN_URI: "http://mirakurun:40772"
    DB_HOST: "mysql"
    DB_USER: "epgstation"
    DB_PASSWORD: "epgstation"
    DB_NAME: "epgstation"


その他の各種設定

・docker-compose.ymlファイルの先頭に"version:"行があれば、不要なので削除する。

・各コンテナはDockerfileでビルドするので、各コンテナサービスの build セクションにmy.Dockerfileを指定する。
・各サービスの"image:"行は削除する。

#version: '3.7'
services:
    mirakurun:
        # image: chinachu/mirakurun
        build:
            context: "./mirakurun"
            dockerfile: "my.Dockerfile"

    mysql:
        # image: mariadb:10.5
        build:
            context: "./mysql"
            dockerfile: "my.Dockerfile"

    epgstation:
        build:
            context: "./epgstation"
            dockerfile: "my.Dockerfile"


作成した docker-compose.yml の全体


~/docker/docker-mirakurun-epgstation/docker-compose.yml

services:
    mirakurun:
        build:
            context: "./mirakurun"
            dockerfile: "my.Dockerfile"

        cap_add:
            - SYS_ADMIN
            - SYS_NICE

        ports:
            - "40772:40772"
            - "9229:9229"

        volumes:
            - ./mirakurun/conf:/app-config
            - ./mirakurun/data:/app-data
            - ./mirakurun/crontab:/var/spool/cron/crontabs
            - /var/usbhdd01/video/mirakurun:/app-recorded

        environment:
            TZ: "Asia/Tokyo"
            LANG: ja_JP.UTF-8
            LC_ALL: ja_JP.UTF-8

        devices:
            - /dev/bus:/dev/bus
            - /dev/dvb:/dev/dvb

        restart: always

        logging:
            driver: json-file
            options:
                max-file: "1"
                max-size: 10m

    mysql:
        build:
            context: "./mysql"
            dockerfile: "my.Dockerfile"

        volumes:
            - mysql-db:/var/lib/mysql
            - /home/tv-xu2404/mysql-share:/tmp
            - ./mysql/crontab:/var/spool/cron/crontabs
            - ./mysql/my.cnf:/etc/mysql/conf.d

        environment:
            MYSQL_USER: epgstation
            MYSQL_PASSWORD: epgstation
            MYSQL_ROOT_PASSWORD: MYSQLROOTPW  # 任意のmysql rootパスワード
            MYSQL_DATABASE: epgstation
            TZ: "Asia/Tokyo"
            LANG: ja_JP.UTF-8
            LC_ALL: ja_JP.UTF-8

        command: --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci --performance-schema=false --expire_logs_days=1
        restart: always
        logging:
            options:
                max-size: "10m"
                max-file: "3"

    epgstation:
        build:
            context: "./epgstation"
            dockerfile: "my.Dockerfile"
        volumes:
            - ./epgstation/config:/app/config
            - ./epgstation/data:/app/data
            - ./epgstation/logs:/app/logs
            - ./epgstation/crontab:/var/spool/cron/crontabs
            - /var/usbhdd01/video/epgstation250928/video:/app/recorded
            - /var/usbhdd01/video/epgstation250928/thumbnail:/app/thumbnail

        environment:
            TZ: "Asia/Tokyo"
            LANG: ja_JP.UTF-8
            LC_ALL: ja_JP.UTF-8
            HOST: "0.0.0.0"
            PORT: "8888"
            REMOTE_MIRAKURUN_URI: "http://mirakurun:40772"
            DB_HOST: "mysql"
            DB_USER: "epgstation"
            DB_PASSWORD: "epgstation"
            DB_NAME: "epgstation"

        depends_on:
            - mirakurun
            - mysql
        ports:
            - "8888:8888"
            - "8889:8889"
        restart: always

volumes:
    mysql-db:
        driver: local


mirakurunをSETUPする。

$ docker compose run --rm -e SETUP=true mirakurun


Mirakurunの tuners.yml は自動作成されるので、作業不要。



Mirakurun用チャンネル情報ファイルを作成する

$ cd ~/docker/docker-mirakurun-epgstation/mirakurun/conf
$ sudo cp channels.yml channels.yml.original
手動でチャンネル設定する場合

参考 mirakurun BSCSチャンネル設定(2025/09/01現在) · GitHub

下記の"神戸チャンネル フォーマット"の内容で ~/docker/docker-mirakurun-epgstation/mirakurun/conf/channels.yml を作成する。

~/docker/docker-mirakurun-epgstation/mirakurun/conf/ 内のファイルのオーナーはrootユーザーになっているので、root権限で編集する。

神戸チャンネル フォーマット 広島チャンネル フォーマット
参照先:Mirakurun : チャンネル設定データ - rokkonet

自動チャンネル設定する場合

地デジ チャンネルスキャン

(mirakurunコンテナが稼働した状態で、Dockerコンテナ内ではなくホスト上で)
$ curl -X PUT "http://localhost:40772/api/config/channels/scan?type=GR&setDisabledOnAdd=false&refresh=true"

BS チャンネルスキャン

(mirakurunコンテナが稼働した状態で、Dockerコンテナ内ではなくホスト上で)
$ curl -X PUT "http://localhost:40772/api/config/channels/scan?type=BS&setDisabledOnAdd=false&refresh=true"


BSを自動チャンネル設定したら1日経過しても番組表が歯抜け状態となったが、mirakurunのGUIのconfigでrestartしたら直った。

EPGStationの設定を行う

最低限の[オリジナルの]config.ymlでTV録画システム起動を確認する


$ cp ~/docker/docker-mirakurun-epgstation/epgstation/config/config.yml ~/docker/docker-mirakurun-epgstation/epgstation/config/config.yml.original

最低限のconfig.yml: ~/docker/docker-mirakurun-epgstation/epgstation/config/config.yml

port: 8888
mirakurunPath: http://mirakurun:40772/

dbtype: mysql
mysql:
    host: mysql
    port: 3306
    user: epgstation
    password: epgstation
    database: epgstation

epgUpdateIntervalTime: 10

conflictPriority: 1
recPriority: 2

recordedFormat: '%YEAR%年%MONTH%月%DAY%日%HOUR%時%MIN%分%SEC%秒-%TITLE%'
recordedFileExtension: .m2ts
recorded:
    - name: recorded
      path: '%ROOT%/recorded'

thumbnail: '%ROOT%/thumbnail'
thumbnailSize: 480x270
thumbnailPosition: 5

ffmpeg: /usr/local/bin/ffmpeg
ffprobe: /usr/local/bin/ffprobe

encodeProcessNum: 4
concurrentEncodeNum: 1
encode:
    - name: H.264
      cmd: '%NODE% %ROOT%/config/enc.js'
      suffix: .mp4
      rate: 4.0

urlscheme:
    m2ts:
        ios: vlc-x-callback://x-callback-url/stream?url=PROTOCOL%3A%2F%2FADDRESS
        android: intent://ADDRESS#Intent;action=android.intent.action.VIEW;type=video/*;scheme=PROTOCOL;end
    video:
        ios: infuse://x-callback-url/play?url=PROTOCOL://ADDRESS
        android: intent://ADDRESS#Intent;action=android.intent.action.VIEW;type=video/*;scheme=PROTOCOL;end
    download:
        ios: vlc-x-callback://x-callback-url/download?url=PROTOCOL%3A%2F%2FADDRESS&filename=FILENAME

stream:
    live:
        ts:
            m2ts:
                - name: 720p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2
                      -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -preset veryfast -y -f mpegts pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2
                      -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -preset veryfast -y -f mpegts pipe:1'
                - name: 無変換

            m2tsll:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -f mpegts -analyzeduration 500000 -i pipe:0 -map 0 -c:s copy -c:d
                      copy -ignore_unknown -fflags nobuffer -flags low_delay -max_delay 250000 -max_interleave_delta 1
                      -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -flags +cgop -vf yadif,scale=-2:720
                      -b:v 3000k -preset veryfast -y -f mpegts pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -f mpegts -analyzeduration 500000 -i pipe:0 -map 0 -c:s copy -c:d
                      copy -ignore_unknown -fflags nobuffer -flags low_delay -max_delay 250000 -max_interleave_delta 1
                      -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -flags +cgop -vf yadif,scale=-2:480
                      -b:v 1500k -preset veryfast -y -f mpegts pipe:1'

            webm:
                - name: 720p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 192k -ac
                      2 -c:v libvpx-vp9 -vf yadif,scale=-2:720 -b:v 3000k -deadline realtime -speed 4 -cpu-used -8 -y -f
                      webm pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 2 -c:a libvorbis -ar 48000 -b:a 128k -ac
                      2 -c:v libvpx-vp9 -vf yadif,scale=-2:480 -b:v 1500k -deadline realtime -speed 4 -cpu-used -8 -y -f
                      webm pipe:1'

            mp4:
                - name: 720p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2
                      -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2
                      -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'

            hls:
                - name: 720p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 17 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -preset veryfast
                      -flags +loop-global_header %OUTPUT%'
                - name: 480p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 17 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -preset veryfast
                      -flags +loop-global_header %OUTPUT%'
    recorded:
        ts:
            webm:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 192k -ac 2
                      -c:v libvpx-vp9 -vf yadif,scale=-2:720 -b:v 3000k -deadline realtime -speed 4 -cpu-used -8 -y -f
                      webm pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 128k -ac 2
                      -c:v libvpx-vp9 -vf yadif,scale=-2:480 -b:v 1500k -deadline realtime -speed 4 -cpu-used -8 -y -f
                      webm pipe:1'

            mp4:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v
                      libx264 -vf yadif,scale=-2:720 -b:v 3000k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v
                      libx264 -vf yadif,scale=-2:480 -b:v 1500k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'

            hls:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -preset veryfast
                      -flags +loop-global_header %OUTPUT%'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -preset veryfast
                      -flags +loop-global_header %OUTPUT%'

        encoded:
            webm:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 3 -c:a libvorbis -ar 48000 -b:a
                      192k -ac 2 -c:v libvpx-vp9 -vf scale=-2:720 -b:v 3000k -deadline realtime -speed 4 -cpu-used -8 -y
                      -f webm pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 3 -c:a libvorbis -ar 48000 -b:a
                      128k -ac 2 -c:v libvpx-vp9 -vf scale=-2:480 -b:v 1500k -deadline realtime -speed 4 -cpu-used -8 -y
                      -f webm pipe:1'

            mp4:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac
                      2 -c:v libx264 -vf scale=-2:720 -b:v 3000k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac
                      2 -c:v libx264 -vf scale=-2:480 -b:v 1500k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'

            hls:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf scale=-2:720 -b:v 3000k -preset veryfast -flags
                      +loop-global_header %OUTPUT%'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf scale=-2:480 -b:v 3000k -preset veryfast -flags
                      +loop-global_header %OUTPUT%'

Dockerコンテナを起動する。

$ cd ~/docker/docker-mirakurun-epgstation
$ docker compose build --no-cache --pull
$ docker compose up

ブラウザで"http://localhost:40772"にアクセスしてMirakurunサーバーへの接続を確認する。

ブラウザで"http://localhost:8888"にアクセスしてEPGStationサーバーへの接続を確認する。

コマンドラインの出力を確認する。

ffmpegの稼働を確認する。
 "docker compose up"コマンドを入力したコマンドライン端末はそのままにして、別のシェルコマンドライン端末を起動する。
 次のコマンドを実行する。

$ cd ~/docker/docker-mirakurun-epgstation
$ docker exec -it $(docker ps | grep docker-mirakurun-epgstation-epgstation | grep -o '^[^ ]*') bash  # epgstationコンテナ内に入る
# which ffmpeg  # ffmpegコマンドの有無とコマンドパスを確認する
# ffmpeg -version  # ffmpegコマンドを実行できることを確認する
# exit  # epgstationコンテナから出る
$ exit  # シェルコマンドライン端末を終了する


MirakurunでNHK BS101チャンネルを視聴する。
 VLCメディアプレーヤーで再生するなら、「ネットワークストリームを開く」メニューに次のURLを指定する。
 http://MIRAKURUN-SERVER:40772/api/channels/BS/BS15_0/services/101/stream

ブラウザでEPGStationサーバーに接続し、放映中画面でライブ視聴ができることを確認する。

"docker compose up"コマンドを実行中のコマンドライン端末でキーボードから"Ctrl-c"を入力し、dockerコンテナを終了する。


dockerコンテナをデーモン起動する。

$ cd ~/docker/docker-mirakurun-epgstation
$ docker compose up -d


EPGStationで録画する。
 番組表が取得されるまでしばらく待機する。
 EPGStationで"TS"形式の録画と"H.264"形式の録画を予約し、録画結果を確認する。

config.ymlをカスタマイズする

~/docker/docker-mirakurun-epgstation/epgstation/config/config.yml に下記の修正(mysqlの文字コード指定、録画ファイル名フォーマット)を行う。

$ diff -u config.yml.original config.yml
--- config.yml.original 2024-05-03 11:40:10.068198955 +0900
+++ config.yml  2024-05-03 11:52:43.442341934 +0900
@@ -14,13 +14,14 @@
     user: epgstation
     password: epgstation
     database: epgstation
+    charset: utf8mb4
 
 epgUpdateIntervalTime: 10
 
 conflictPriority: 1
 recPriority: 2
 
-recordedFormat: '%YEAR%年%MONTH%月%DAY%日%HOUR%時%MIN%分%SEC%秒-%TITLE%'
+recordedFormat: '%SHORTYEAR%%MONTH%%DAY%%HOUR%%MIN%%TYPE%%CH%-%SID%-%CHNAME%-%TITLE%'
 recordedFileExtension: .m2ts
 recorded:
     - name: recorded


H.264エンコード設定増設

次の3モードを追加する。

  • 音声欠落を防ぐため、冒頭4秒間をカットして H.264にエンコードする設定
  • NHK BSドキュメンタリー H.264エンコード設定
  • NHK19時ニュース H.264エンコード設定

次の参照先にしたがって、設定する。
 参照先 EPGStation : エンコードモード追加 - rokkonet

作成したconfig.yml
port: 8888
mirakurunPath: http://mirakurun:40772/

# dbtype: sqlite
# sqlite:
#   extensions:
#     - '/hoge/regexp.dylib'
#   regexp: true

dbtype: mysql
mysql:
    host: mysql
    port: 3306
    user: epgstation
    password: epgstation
    database: epgstation
    charset: utf8mb4

epgUpdateIntervalTime: 10

conflictPriority: 1
recPriority: 2

#recordedFormat: '%YEAR%年%MONTH%月%DAY%日%HOUR%時%MIN%分%SEC%秒-%TITLE%'
recordedFormat: '%SHORTYEAR%%MONTH%%DAY%%HOUR%%MIN%%TYPE%%CH%-%SID%-%CHNAME%-%TITLE%'
recordedFileExtension: .m2ts
recorded:
    - name: recorded
      path: '%ROOT%/recorded'

thumbnail: '%ROOT%/thumbnail'
thumbnailSize: 480x270
thumbnailPosition: 5

ffmpeg: /usr/local/bin/ffmpeg
ffprobe: /usr/local/bin/ffprobe

encodeProcessNum: 4
concurrentEncodeNum: 1
encode:
    - name: H.264-cut4secs
      cmd: '%NODE% %ROOT%/config/enc-cut4secs.js'
      suffix: .mp4
      rate: 4.0

    - name: H.264-nhk_bs_docu-cut4secs
      cmd: '%NODE% %ROOT%/config/enc-bs_docu-cut4secs.js'
      suffix: .mp4
      rate: 4.0

    - name: H.264-nhk_news-cut4secs
      cmd: '%NODE% %ROOT%/config/enc-nhk_news-cut4secs.js'
      suffix: .mp4
      rate: 4.0

    - name: H.264
      cmd: '%NODE% %ROOT%/config/enc.js'
      suffix: .mp4
      rate: 4.0

urlscheme:
    m2ts:
        ios: vlc-x-callback://x-callback-url/stream?url=PROTOCOL%3A%2F%2FADDRESS
        android: intent://ADDRESS#Intent;action=android.intent.action.VIEW;type=video/*;scheme=PROTOCOL;end
    video:
        ios: infuse://x-callback-url/play?url=PROTOCOL://ADDRESS
        android: intent://ADDRESS#Intent;action=android.intent.action.VIEW;type=video/*;scheme=PROTOCOL;end
    download:
        ios: vlc-x-callback://x-callback-url/download?url=PROTOCOL%3A%2F%2FADDRESS&filename=FILENAME

stream:
    live:
        ts:
            m2ts:
                - name: 720p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2
                      -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -preset veryfast -y -f mpegts pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2
                      -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -preset veryfast -y -f mpegts pipe:1'
                - name: 無変換

            m2tsll:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -f mpegts -analyzeduration 500000 -i pipe:0 -map 0 -c:s copy -c:d
                      copy -ignore_unknown -fflags nobuffer -flags low_delay -max_delay 250000 -max_interleave_delta 1
                      -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -flags +cgop -vf yadif,scale=-2:720
                      -b:v 3000k -preset veryfast -y -f mpegts pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -f mpegts -analyzeduration 500000 -i pipe:0 -map 0 -c:s copy -c:d
                      copy -ignore_unknown -fflags nobuffer -flags low_delay -max_delay 250000 -max_interleave_delta 1
                      -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -flags +cgop -vf yadif,scale=-2:480
                      -b:v 1500k -preset veryfast -y -f mpegts pipe:1'

            webm:
                - name: 720p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 192k -ac
                      2 -c:v libvpx-vp9 -vf yadif,scale=-2:720 -b:v 3000k -deadline realtime -speed 4 -cpu-used -8 -y -f
                      webm pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 2 -c:a libvorbis -ar 48000 -b:a 128k -ac
                      2 -c:v libvpx-vp9 -vf yadif,scale=-2:480 -b:v 1500k -deadline realtime -speed 4 -cpu-used -8 -y -f
                      webm pipe:1'

            mp4:
                - name: 720p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2
                      -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2
                      -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'

            hls:
                - name: 720p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 17 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -preset veryfast
                      -flags +loop-global_header %OUTPUT%'
                - name: 480p
                  cmd:
                      '%FFMPEG% -re -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 17 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -preset veryfast
                      -flags +loop-global_header %OUTPUT%'
    recorded:
        ts:
            webm:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 192k -ac 2
                      -c:v libvpx-vp9 -vf yadif,scale=-2:720 -b:v 3000k -deadline realtime -speed 4 -cpu-used -8 -y -f
                      webm pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 3 -c:a libvorbis -ar 48000 -b:a 128k -ac 2
                      -c:v libvpx-vp9 -vf yadif,scale=-2:480 -b:v 1500k -deadline realtime -speed 4 -cpu-used -8 -y -f
                      webm pipe:1'

            mp4:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac 2 -c:v
                      libx264 -vf yadif,scale=-2:720 -b:v 3000k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac 2 -c:v
                      libx264 -vf yadif,scale=-2:480 -b:v 1500k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'

            hls:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf yadif,scale=-2:720 -b:v 3000k -preset veryfast
                      -flags +loop-global_header %OUTPUT%'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -i pipe:0 -sn -map 0 -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf yadif,scale=-2:480 -b:v 1500k -preset veryfast
                      -flags +loop-global_header %OUTPUT%'

        encoded:
            webm:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 3 -c:a libvorbis -ar 48000 -b:a
                      192k -ac 2 -c:v libvpx-vp9 -vf scale=-2:720 -b:v 3000k -deadline realtime -speed 4 -cpu-used -8 -y
                      -f webm pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 3 -c:a libvorbis -ar 48000 -b:a
                      128k -ac 2 -c:v libvpx-vp9 -vf scale=-2:480 -b:v 1500k -deadline realtime -speed 4 -cpu-used -8 -y
                      -f webm pipe:1'

            mp4:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -c:a aac -ar 48000 -b:a 192k -ac
                      2 -c:v libx264 -vf scale=-2:720 -b:v 3000k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -c:a aac -ar 48000 -b:a 128k -ac
                      2 -c:v libx264 -vf scale=-2:480 -b:v 1500k -profile:v baseline -preset veryfast -tune
                      fastdecode,zerolatency -movflags frag_keyframe+empty_moov+faststart+default_base_moof -y -f mp4
                      pipe:1'

            hls:
                - name: 720p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 192k -ac 2 -c:v libx264 -vf scale=-2:720 -b:v 3000k -preset veryfast -flags
                      +loop-global_header %OUTPUT%'
                - name: 480p
                  cmd:
                      '%FFMPEG% -dual_mono_mode main -ss %SS% -i %INPUT% -sn -threads 0 -ignore_unknown
                      -max_muxing_queue_size 1024 -f hls -hls_time 3 -hls_list_size 0 -hls_allow_cache 1
                      -hls_segment_filename %streamFileDir%/stream%streamNum%-%09d.ts -hls_flags delete_segments -c:a
                      aac -ar 48000 -b:a 128k -ac 2 -c:v libx264 -vf scale=-2:480 -b:v 3000k -preset veryfast -flags
                      +loop-global_header %OUTPUT%'


TV録画システム起動

稼働中の TV録画システムを終了する。
$ cd ~/docker/docker-mirakurun-epgstation
$ docker compose down

TV録画システムを再ビルドする
 $ docker compose build --no-cache
  ホスト共有ディレクトリ以外のコンテナ内のデータは、失われる。

TV録画システムを起動する。
・ブラウザの設定でキャッシュを削除する。
・TV録画システムを起動する。
  $ docker compose up -d
・TVシステム起動後、ブラウザでMirakurun・EPGStationのページを表示し、"Ctrl + Shift + R"でハードリロードする。
(設定を変更した時は、ブラウザのキャッシュを削除し、ブラウザのページを"Ctrl + Shift + R"でハードリロードする)

Mirakurun、EPGStation各コンテナ内ディレクトリとホストのディレクトリの共用設定反映を確認する。

Mirakurunコンテナ内のcron自動起動設定

次のページにしたがって、cron自動起動を設定する。
Docker版mirakurun:vim・ less・cron・ps組み込み、locale・タイムゾーン設定とcronの自動起動設定 - rokkonet

mirakutunコンテナ内で"crontab -e"を実行し、コマンド記述部の上部に次の変数設定を記述する。

PATH=/app-data/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin  # 適当に検索パスを増やす
LANG=ja_JP.UTF-8
LC_ALL=ja_JP.UTF-8

cronで適当なコマンドを実行してcronの稼働、時間設定(JSTかUTCか)を確認する。
 例)下記cron設定で、cronの稼働、時間設定、共有ディレクトリの確認ができる。

39 21 * * * /usr/bin/touch /app-recorded/"$(date)"

MySQL設定ファイル作成

MySQL接続のパスワードを不要にする。
次の内容の"~/docker/docker-mirakurun-epgstation/mysql/my.cnf/50-client.cnf"ファイルを作成する。
(ファイル名は、拡張子が.cnfであれば何でもよい)

[client]
user=epgstation
password=epgstation
host=localhost

ファイル属性を設定する

$ sudo chown 999:999 ~/docker/docker-mirakurun-epgstation/mysql/my.cnf/50-client.cnf  # mysqlコンテナ内のmysqlユーザーID999に合わせる
$ sudo chmod 600 ~/docker/docker-mirakurun-epgstation/mysql/my.cnf/50-client.cnf


MySQLコンテナのcronのロケール設定

MySQLコンテナ内で"crontab -e"を実行し、コマンド記述部の上部に次の変数設定を記述する。

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin  # 適当に検索パスを増やす
LANG=ja_JP.UTF-8
LC_ALL=ja_JP.UTF-8


EPGStationの録画データのテキストファイル保存

MySQLコンテナ内のcron用のシェルスクリプトを作成する

ホストにて次のコマンドでMySQLコンテナに入る。
$ docker exec -it $(docker ps | grep 'mysql' | awk '{print $1}') bash

/usr/local/bin/saveEpgstationDataAsText.sh を次の内容で作成する。
スクリプト中のSHAREはホストと共有のディレクトリとする。この記事では"/tmp"ディレクトリ。
スクリプト中のUSERNAMEとDATABASENAMEは、この記事ではepgstation。

参考: EPGStationの録画データをテキストファイルに保存する - rokkonet

#!/usr/bin/env sh
# save epgstation-recorded-data in mysql to text-file.

if [ -f  SHARE/epgstation-recorded-information.txt ]; then
   mv SHARE/epgstation-recorded-information.txt  SHARE/epgstation-recorded-information-prev.txt
fi

mysql -u USERNAME DATABASENAME -B -e "SELECT video_file.id, video_file.parentDirectoryName, video_file.filePath, video_file.type, video_file.name, video_file.size, video_file.recordedId, recorded.startAt, recorded.endAt, recorded.name, recorded.description, recorded.extended FROM video_file LEFT OUTER JOIN recorded ON video_file.recordedId = recorded.id;" > SHARE/epgstation-recorded-information.txt

exit 0

ファイルに実行属性を与える。
# chmod 744 /usr/local/bin/saveEpgstationDataAsText.sh

cron設定する

ホストにて次のコマンドでMySQLコンテナに入る。
$ docker exec -it $(docker ps | grep 'mysql' | awk '{print $1}') bash

"crontab -e"コマンドで次の設定を記述する。

PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
LANG=ja_JP.UTF-8
LC_ALL=ja_JP.UTF-8

# m h  dom mon dow   command
1   3   *   *   *   /usr/local/bin/saveEpgstationDataAsText.sh


トラブル等でのログの確認

$ su - tv
$ docker logs docker-mirakurun-epgstation-epgstation-1  # docker-mirakurun、、、の途中でTabキーを押せば補完される
$ docker compose logs -f mirakurun
$ docker compose logs -f epgstation
$ docker compose logs -f mysql


稼働しているTV録画システムの終了と再起動

録画ファイル・サムネイルファイル・mysql内の情報(予約情報・録画情報等)は削除されない。

$ cd ~/docker/docker-mirakurun-epgstation

## 稼働状況確認
$ docker ps

## コンテナ内データが削除されない終了
$ docker compose down

## コンテナ内データが削除される終了
##   mysql内の情報(予約情報・録画情報等)等、コンテナ内のデータは削除される
##   録画ファイル・サムネイルファイルはホストとの共有ディレクトリに保存していない限り削除される
$ docker compose down -v

## 既存コンテナの起動
$ docker compose up -d
Docker内ソフトの設定を変更した時は、ブラウザのキャッシュを削除し、ブラウザのページを"Ctrl + Shift + R"でハードリロードする。

## キャッシュを利用せずにホスト内の既存コンテナイメージからビルド
$ docker compose build --no-cache

## キャッシュを効かせてホスト内の既存コンテナイメージからビルド
$ docker compose build

## コンテナをビルドして起動
$ docker compose up --build -d
上記コマンド実行後、ブラウザのキャッシュを削除し、ブラウザのページを"Ctrl + Shift + R"でハードリロードする。

## コンテナイメージをダウンロードしキャッシュを使わずビルド
$ docker compose build --no-cache --pull

## コンテナイメージをダウンロードしキャッシュを効かせてビルド
$ docker compose build --pull


"docker compose down"コマンドについて

docker compose down

Composeファイルで定義されたサービスを停止し、関連するコンテナを削除するが、ボリューム(データ)は削除されない。
つまり、ボリュームデータは残る。

docker compose down -v

Composeファイルで定義されたサービスを停止し、関連するコンテナを削除するだけでなく、ボリューム(データ)も削除する。
つまり、ボリュームデータも一緒に削除される。

ボリュームデータを保持して再利用したい場合は、"docker compose down"コマンドを使用し、ボリュームデータも削除したい場合は、"docker compose down -v"コマンドを使用する。
ホストとの共有ディレクトリ内のデータは削除されない。

設定変更手順

Dockerコンテナ内のソフトの設定変更の反映

(1)Dockerコンテナを停止する
  $ docker compose down

(2)Dockerコンテナを起動する
  $ docker compose up -d

(3)ブラウザのキャッシュを削除する

(4)操作するブラウザのページを"Ctrl + Shift + R"でハードリロードする

その他

 参考ページ Docker Composeにおける各種ファイルの変更時の反映 #docker-compose - Qiita

docker-compose.ymlを修正する場合

要注意

mysql内の情報(予約情報・録画情報等)等、コンテナ内のデータは削除される。
録画ファイル・サムネイルファイルはホスト共有ディレクトリに保存していない限り削除される。

手順
  • コンテナを終了・ボリュームを削除する。
    $ cd ~/docker/docker-mirakurun-epgstation
    $ docker compose down -v

  • 設定変更作業を行う。

  • コンテナを起動する。
    $ docker compose up -d

    ブラウザのキャッシュを削除し、ブラウザのページを"Ctrl + Shift + R"でハードリロードする。

    Dockerfileを修正する場合

    コンテナを再構築して、再起動する。

    要注意
    mysql内の情報(予約情報・録画情報等)等、コンテナ内のデータは削除される。
    録画ファイル・サムネイルファイルはコンテナ外に保存していない限り削除される。

    手順
  • コンテナを終了・ボリュームを削除する。
    $ cd ~/docker/docker-mirakurun-epgstation
    $ docker compose down -v

  • 設定変更作業を行う。

  • イメージ取得・ビルド・コンテナ起動を行う。
    $ docker compose build --pull
    $ docker compose up -d
    ブラウザのキャッシュを削除し、ブラウザのページを"Ctrl + Shift + R"でハードリロードする。

"--pull"オプションを使用すると、Composeがビルドプロセスの一部としてイメージをプルするように指示する。つまり、ローカルにそのイメージが存在しない場合でも、常に最新のイメージを取得してビルドする。これにより、常に最新の状態でアプリケーションをビルドおよび実行できる。

EPGStationコンテナの停止

$ cd ~/docker/docker-mirakurun-epgstation
$ docker compose kill epgstation
$ docker compose rm -f epgstation

"docker compose kill"コマンドは、Composeで定義されたサービスのコンテナを強制的に停止するが、ボリュームデータを削除しない。このコマンドは単にコンテナを停止するだけであり、ボリュームデータには影響を与えない。

"docker compose rm -f"コマンドは、Composeファイルで定義されたサービスのコンテナを強制的に削除する。このコマンドを実行すると、実行中のコンテナを強制的に停止し、その後削除する。ボリュームデータは削除されない。つまり、関連するコンテナが削除されるが、ボリュームデータは残る。

epgstation以外でのmysqlの利用

tvユーザでmysqlコンテナに入ってmysqlを利用する。

(tvユーザでログインする)
$ su - tv

(mysqlコンテナIDを確認する)
$ docker ps

(mysqlコンテナに入る)
$ docker exec -it MYSQLCONTAINERID /bin/bash

(mysqlクライアントを起動する)
# mysql -u USER -p DATABASE

コンテナ外とのデータ共有

docker-compose.ymlに"- /home/tv-USER/mysql-share:/tmp"を記述したので、コンテナ内の"/tmp"ディレクトリがホストの"/home/tv-USER/mysql-share"と共有される。

mirakurunのログ取得(コンテナのログ収録)

$ docker logs MIRAKURUN-CONTAINER-ID


TV番組録画、ストリーミング再生

https://rasumus.hatenablog.com/entry/2025/01/11/234619